From 3226e9604b006e15434bcc273b4f972a47fa1cea Mon Sep 17 00:00:00 2001 From: lukachi Date: Mon, 3 Aug 2026 12:55:59 +0300 Subject: [PATCH 001/124] init workflow --- .../skills/align-project-knowledge/SKILL.md | 91 +++++ .../agents/openai.yaml | 4 + .agents/skills/analyze-with-graphify/SKILL.md | 75 ++++ .../analyze-with-graphify/agents/openai.yaml | 4 + .../curate-engineering-knowledge/SKILL.md | 55 +++ .../agents/openai.yaml | 4 + .../assets/engineering-concept.md | 76 ++++ .../engineering-writing-contract.md | 51 +++ .../skills/curate-product-knowledge/SKILL.md | 83 ++++ .../agents/openai.yaml | 4 + .../assets/product-concept.md | 99 +++++ .../references/product-writing-contract.md | 87 ++++ .../skills/curate-project-knowledge/SKILL.md | 110 +++++ .../agents/openai.yaml | 4 + .../assets/area-index.md | 61 +++ .../assets/decision.md | 86 ++++ .../references/knowledge-model.md | 247 +++++++++++ .../skills/explore-project-knowledge/SKILL.md | 95 +++++ .../agents/openai.yaml | 4 + .../references/exploration-contract.md | 77 ++++ .agents/skills/implement-work-item/SKILL.md | 104 +++++ .../implement-work-item/agents/openai.yaml | 6 + .../references/execution-contract.md | 29 ++ .agents/skills/manage-project-work/SKILL.md | 158 +++++++ .../manage-project-work/agents/openai.yaml | 4 + .../assets/bundle-review.md | 11 + .../manage-project-work/assets/capture.md | 33 ++ .../assets/wayfinder-map.md | 36 ++ .../manage-project-work/assets/work-issue.md | 70 ++++ .../manage-project-work/assets/work-spec.md | 177 ++++++++ .agents/skills/qmd/SKILL.md | 295 ++++++++++++++ .agents/skills/qmd/references/mcp-setup.md | 102 +++++ .../setup-workflow-environment/SKILL.md | 132 ++++++ .../agents/openai.yaml | 4 + .../references/install-contract.md | 140 +++++++ .../skills/shape-project-direction/SKILL.md | 71 ++++ .../agents/openai.yaml | 6 + .../references/direction-shaping-contract.md | 59 +++ .../skills/specify-project-change/SKILL.md | 85 ++++ .../specify-project-change/agents/openai.yaml | 6 + .../references/specification-contract.md | 29 ++ .agents/skills/split-project-change/SKILL.md | 67 +++ .../split-project-change/agents/openai.yaml | 6 + .../references/issue-design-contract.md | 27 ++ .../skills/verify-knowledge-quality/SKILL.md | 95 +++++ .../agents/openai.yaml | 4 + .../references/authority-review.md | 35 ++ .../references/quality-rubric.md | 102 +++++ .../references/reader-communication-review.md | 40 ++ .agents/skills/verify-project-work/SKILL.md | 106 +++++ .../verify-project-work/agents/openai.yaml | 4 + .../references/completion-gate.md | 44 ++ .claude/rules/evidence-first.md | 60 +++ .claude/rules/execution-continuity.md | 37 ++ .claude/rules/maintainer-review.md | 43 ++ .claude/rules/project-work-bundles.md | 43 ++ .claude/rules/project-work-lifecycle.md | 44 ++ .claude/rules/workflow-routing.md | 28 ++ .claude/settings.json | 26 ++ .../skills/align-project-knowledge/SKILL.md | 91 +++++ .../agents/openai.yaml | 4 + .claude/skills/analyze-with-graphify/SKILL.md | 75 ++++ .../analyze-with-graphify/agents/openai.yaml | 4 + .../curate-engineering-knowledge/SKILL.md | 55 +++ .../agents/openai.yaml | 4 + .../assets/engineering-concept.md | 76 ++++ .../engineering-writing-contract.md | 51 +++ .../skills/curate-product-knowledge/SKILL.md | 83 ++++ .../agents/openai.yaml | 4 + .../assets/product-concept.md | 99 +++++ .../references/product-writing-contract.md | 87 ++++ .../skills/curate-project-knowledge/SKILL.md | 110 +++++ .../agents/openai.yaml | 4 + .../assets/area-index.md | 61 +++ .../assets/decision.md | 86 ++++ .../references/knowledge-model.md | 247 +++++++++++ .../skills/explore-project-knowledge/SKILL.md | 95 +++++ .../agents/openai.yaml | 4 + .../references/exploration-contract.md | 77 ++++ .claude/skills/implement-work-item/SKILL.md | 104 +++++ .../implement-work-item/agents/openai.yaml | 6 + .../references/execution-contract.md | 29 ++ .claude/skills/manage-project-work/SKILL.md | 158 +++++++ .../manage-project-work/agents/openai.yaml | 4 + .../assets/bundle-review.md | 11 + .../manage-project-work/assets/capture.md | 33 ++ .../assets/wayfinder-map.md | 36 ++ .../manage-project-work/assets/work-issue.md | 70 ++++ .../manage-project-work/assets/work-spec.md | 177 ++++++++ .claude/skills/qmd/SKILL.md | 295 ++++++++++++++ .claude/skills/qmd/references/mcp-setup.md | 102 +++++ .../setup-workflow-environment/SKILL.md | 132 ++++++ .../agents/openai.yaml | 4 + .../references/install-contract.md | 140 +++++++ .../skills/shape-project-direction/SKILL.md | 71 ++++ .../agents/openai.yaml | 6 + .../references/direction-shaping-contract.md | 59 +++ .../skills/specify-project-change/SKILL.md | 85 ++++ .../specify-project-change/agents/openai.yaml | 6 + .../references/specification-contract.md | 29 ++ .claude/skills/split-project-change/SKILL.md | 67 +++ .../split-project-change/agents/openai.yaml | 6 + .../references/issue-design-contract.md | 27 ++ .../skills/verify-knowledge-quality/SKILL.md | 95 +++++ .../agents/openai.yaml | 4 + .../references/authority-review.md | 35 ++ .../references/quality-rubric.md | 102 +++++ .../references/reader-communication-review.md | 40 ++ .claude/skills/verify-project-work/SKILL.md | 106 +++++ .../verify-project-work/agents/openai.yaml | 4 + .../references/completion-gate.md | 44 ++ .gitignore | 4 + .graphifyignore | 39 ++ .workflow/.gitignore | 2 + .workflow/config.json | 15 + .workflow/rules/evidence-first.md | 60 +++ .workflow/rules/execution-continuity.md | 37 ++ .workflow/rules/maintainer-review.md | 43 ++ .workflow/rules/project-work-bundles.md | 43 ++ .workflow/rules/project-work-lifecycle.md | 44 ++ .workflow/rules/workflow-routing.md | 28 ++ .workflow/runtime/guard-background-bash.mjs | 67 +++ .workflow/runtime/guard-stop.mjs | 210 ++++++++++ .workflow/runtime/idle-guard.sh | 159 ++++++++ .workflow/state.json | 55 +++ AGENTS.md | 133 ++++++ CLAUDE.md | 1 + PROJECT_WORKFLOW.md | 384 ++++++++++++++++++ skills-lock.json | 80 ++++ 129 files changed, 8493 insertions(+) create mode 100644 .agents/skills/align-project-knowledge/SKILL.md create mode 100644 .agents/skills/align-project-knowledge/agents/openai.yaml create mode 100644 .agents/skills/analyze-with-graphify/SKILL.md create mode 100644 .agents/skills/analyze-with-graphify/agents/openai.yaml create mode 100644 .agents/skills/curate-engineering-knowledge/SKILL.md create mode 100644 .agents/skills/curate-engineering-knowledge/agents/openai.yaml create mode 100644 .agents/skills/curate-engineering-knowledge/assets/engineering-concept.md create mode 100644 .agents/skills/curate-engineering-knowledge/references/engineering-writing-contract.md create mode 100644 .agents/skills/curate-product-knowledge/SKILL.md create mode 100644 .agents/skills/curate-product-knowledge/agents/openai.yaml create mode 100644 .agents/skills/curate-product-knowledge/assets/product-concept.md create mode 100644 .agents/skills/curate-product-knowledge/references/product-writing-contract.md create mode 100644 .agents/skills/curate-project-knowledge/SKILL.md create mode 100644 .agents/skills/curate-project-knowledge/agents/openai.yaml create mode 100644 .agents/skills/curate-project-knowledge/assets/area-index.md create mode 100644 .agents/skills/curate-project-knowledge/assets/decision.md create mode 100644 .agents/skills/curate-project-knowledge/references/knowledge-model.md create mode 100644 .agents/skills/explore-project-knowledge/SKILL.md create mode 100644 .agents/skills/explore-project-knowledge/agents/openai.yaml create mode 100644 .agents/skills/explore-project-knowledge/references/exploration-contract.md create mode 100644 .agents/skills/implement-work-item/SKILL.md create mode 100644 .agents/skills/implement-work-item/agents/openai.yaml create mode 100644 .agents/skills/implement-work-item/references/execution-contract.md create mode 100644 .agents/skills/manage-project-work/SKILL.md create mode 100644 .agents/skills/manage-project-work/agents/openai.yaml create mode 100644 .agents/skills/manage-project-work/assets/bundle-review.md create mode 100644 .agents/skills/manage-project-work/assets/capture.md create mode 100644 .agents/skills/manage-project-work/assets/wayfinder-map.md create mode 100644 .agents/skills/manage-project-work/assets/work-issue.md create mode 100644 .agents/skills/manage-project-work/assets/work-spec.md create mode 100644 .agents/skills/qmd/SKILL.md create mode 100644 .agents/skills/qmd/references/mcp-setup.md create mode 100644 .agents/skills/setup-workflow-environment/SKILL.md create mode 100644 .agents/skills/setup-workflow-environment/agents/openai.yaml create mode 100644 .agents/skills/setup-workflow-environment/references/install-contract.md create mode 100644 .agents/skills/shape-project-direction/SKILL.md create mode 100644 .agents/skills/shape-project-direction/agents/openai.yaml create mode 100644 .agents/skills/shape-project-direction/references/direction-shaping-contract.md create mode 100644 .agents/skills/specify-project-change/SKILL.md create mode 100644 .agents/skills/specify-project-change/agents/openai.yaml create mode 100644 .agents/skills/specify-project-change/references/specification-contract.md create mode 100644 .agents/skills/split-project-change/SKILL.md create mode 100644 .agents/skills/split-project-change/agents/openai.yaml create mode 100644 .agents/skills/split-project-change/references/issue-design-contract.md create mode 100644 .agents/skills/verify-knowledge-quality/SKILL.md create mode 100644 .agents/skills/verify-knowledge-quality/agents/openai.yaml create mode 100644 .agents/skills/verify-knowledge-quality/references/authority-review.md create mode 100644 .agents/skills/verify-knowledge-quality/references/quality-rubric.md create mode 100644 .agents/skills/verify-knowledge-quality/references/reader-communication-review.md create mode 100644 .agents/skills/verify-project-work/SKILL.md create mode 100644 .agents/skills/verify-project-work/agents/openai.yaml create mode 100644 .agents/skills/verify-project-work/references/completion-gate.md create mode 100644 .claude/rules/evidence-first.md create mode 100644 .claude/rules/execution-continuity.md create mode 100644 .claude/rules/maintainer-review.md create mode 100644 .claude/rules/project-work-bundles.md create mode 100644 .claude/rules/project-work-lifecycle.md create mode 100644 .claude/rules/workflow-routing.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/align-project-knowledge/SKILL.md create mode 100644 .claude/skills/align-project-knowledge/agents/openai.yaml create mode 100644 .claude/skills/analyze-with-graphify/SKILL.md create mode 100644 .claude/skills/analyze-with-graphify/agents/openai.yaml create mode 100644 .claude/skills/curate-engineering-knowledge/SKILL.md create mode 100644 .claude/skills/curate-engineering-knowledge/agents/openai.yaml create mode 100644 .claude/skills/curate-engineering-knowledge/assets/engineering-concept.md create mode 100644 .claude/skills/curate-engineering-knowledge/references/engineering-writing-contract.md create mode 100644 .claude/skills/curate-product-knowledge/SKILL.md create mode 100644 .claude/skills/curate-product-knowledge/agents/openai.yaml create mode 100644 .claude/skills/curate-product-knowledge/assets/product-concept.md create mode 100644 .claude/skills/curate-product-knowledge/references/product-writing-contract.md create mode 100644 .claude/skills/curate-project-knowledge/SKILL.md create mode 100644 .claude/skills/curate-project-knowledge/agents/openai.yaml create mode 100644 .claude/skills/curate-project-knowledge/assets/area-index.md create mode 100644 .claude/skills/curate-project-knowledge/assets/decision.md create mode 100644 .claude/skills/curate-project-knowledge/references/knowledge-model.md create mode 100644 .claude/skills/explore-project-knowledge/SKILL.md create mode 100644 .claude/skills/explore-project-knowledge/agents/openai.yaml create mode 100644 .claude/skills/explore-project-knowledge/references/exploration-contract.md create mode 100644 .claude/skills/implement-work-item/SKILL.md create mode 100644 .claude/skills/implement-work-item/agents/openai.yaml create mode 100644 .claude/skills/implement-work-item/references/execution-contract.md create mode 100644 .claude/skills/manage-project-work/SKILL.md create mode 100644 .claude/skills/manage-project-work/agents/openai.yaml create mode 100644 .claude/skills/manage-project-work/assets/bundle-review.md create mode 100644 .claude/skills/manage-project-work/assets/capture.md create mode 100644 .claude/skills/manage-project-work/assets/wayfinder-map.md create mode 100644 .claude/skills/manage-project-work/assets/work-issue.md create mode 100644 .claude/skills/manage-project-work/assets/work-spec.md create mode 100644 .claude/skills/qmd/SKILL.md create mode 100644 .claude/skills/qmd/references/mcp-setup.md create mode 100644 .claude/skills/setup-workflow-environment/SKILL.md create mode 100644 .claude/skills/setup-workflow-environment/agents/openai.yaml create mode 100644 .claude/skills/setup-workflow-environment/references/install-contract.md create mode 100644 .claude/skills/shape-project-direction/SKILL.md create mode 100644 .claude/skills/shape-project-direction/agents/openai.yaml create mode 100644 .claude/skills/shape-project-direction/references/direction-shaping-contract.md create mode 100644 .claude/skills/specify-project-change/SKILL.md create mode 100644 .claude/skills/specify-project-change/agents/openai.yaml create mode 100644 .claude/skills/specify-project-change/references/specification-contract.md create mode 100644 .claude/skills/split-project-change/SKILL.md create mode 100644 .claude/skills/split-project-change/agents/openai.yaml create mode 100644 .claude/skills/split-project-change/references/issue-design-contract.md create mode 100644 .claude/skills/verify-knowledge-quality/SKILL.md create mode 100644 .claude/skills/verify-knowledge-quality/agents/openai.yaml create mode 100644 .claude/skills/verify-knowledge-quality/references/authority-review.md create mode 100644 .claude/skills/verify-knowledge-quality/references/quality-rubric.md create mode 100644 .claude/skills/verify-knowledge-quality/references/reader-communication-review.md create mode 100644 .claude/skills/verify-project-work/SKILL.md create mode 100644 .claude/skills/verify-project-work/agents/openai.yaml create mode 100644 .claude/skills/verify-project-work/references/completion-gate.md create mode 100644 .graphifyignore create mode 100644 .workflow/.gitignore create mode 100644 .workflow/config.json create mode 100644 .workflow/rules/evidence-first.md create mode 100644 .workflow/rules/execution-continuity.md create mode 100644 .workflow/rules/maintainer-review.md create mode 100644 .workflow/rules/project-work-bundles.md create mode 100644 .workflow/rules/project-work-lifecycle.md create mode 100644 .workflow/rules/workflow-routing.md create mode 100644 .workflow/runtime/guard-background-bash.mjs create mode 100644 .workflow/runtime/guard-stop.mjs create mode 100644 .workflow/runtime/idle-guard.sh create mode 100644 .workflow/state.json create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 PROJECT_WORKFLOW.md create mode 100644 skills-lock.json diff --git a/.agents/skills/align-project-knowledge/SKILL.md b/.agents/skills/align-project-knowledge/SKILL.md new file mode 100644 index 0000000..8832718 --- /dev/null +++ b/.agents/skills/align-project-knowledge/SKILL.md @@ -0,0 +1,91 @@ +--- +name: align-project-knowledge +description: Align a significant task's shaping spec with current project purpose, Areas, capabilities, flows, architecture, decisions, repository responsibilities, and known uncertainties. Use after the shaping record exists and before choosing a design, approving framing, changing a contract or flow, or making assumptions about why the project behaves as it does. +--- + +# Align Project Knowledge + +Do not design from code and memory alone. Establish the project's current intent before proposing a solution. + +## Procedure + +1. Read `.workflow/config.json` and resolve the configured knowledge repository. +2. Inspect the current session skill catalog and require the official native + `qmd` skill. Invoke it before retrieval. If it is absent, stop and ask to + invoke `setup-workflow-environment` to repair or reinstall the selected + project/user skills, then ask only for the unavoidable agent-session + restart. An on-disk file alone does not prove the running agent loaded it. +3. Run QMD from that knowledge root. Require `qmd status`; if QMD or the + project-local `.qmd/index.yml` is missing, stop and report the broken + workflow environment. +4. Run `wfctl knowledge build --target `. Stop alignment if + validation, knowledge-graph compilation, or claim-ledger compilation fails; + do not silently reason over broken navigation. The generated + `.workflow/current/knowledge-graph.json` is disposable knowledge navigation. + `.workflow/current/claim-ledger.json` is disposable explicit candidate + lineage. Neither is authority or an edit target. +5. Start at `knowledge/index.md`, then use `qmd search ... -c knowledge` for + exact terms or a structured `qmd query` with authored `intent:`, `lex:`, + `vec:`, and when useful `hyde:` fields for hybrid retrieval. + If QMD MCP is available, use `query` with + `collections: ["knowledge"]`. Open the returned concepts directly and use + directory indexes for progressive disclosure. Retrieval ranking is not + authority. +6. Expand the QMD candidates through explicit incoming and outgoing edges in + the compiled graph. Follow material typed relationships, Area ownership, + decision lineage, and human-authored links so lexical similarity does not + define the task boundary. +7. Open only concepts relevant to the work, including: + - vision and non-goals, + - the relevant Area index, capabilities, concepts, rules, and flows, + - architectural boundaries, + - current and superseded decisions, + - repository responsibilities, + - recorded uncertainties. +8. Inspect `status`, `generated`, `verified.content_hash`, `stale_after`, and + `sources` before treating a concept as authoritative. `wfctl knowledge + validate` must prove that at least one verification matches the current + material content. +9. Follow links to predecessor decisions and supporting sources when the proposed work depends on them. +10. Compare the proposed behavior with both code evidence and curated intent. +11. Treat only `knowledge/` as the default current-knowledge surface. Do not + consult `raw/` or `intake/` to fill a gap. +12. Record QMD queries, graph-expanded concept paths, directly reviewed + concept paths, constraints, and any conflict in the central change bundle. + +When a bundle already exists, run `wfctl work status ` and stage-specific +`wfctl work context ` first. Read curated knowledge from its `Knowledge +root`, update only the returned bundle files, and inspect implementation only +from exact `Code roots`. Do not treat the knowledge repository as the +implementation checkout. + +## When there is no baseline yet + +An existing project installed into this workflow starts with an empty or barely +populated `knowledge/`, and that is a supported state, not an error. A +reconstruction is expensive enough that nobody runs one before their first fix, +so most first tasks in a real repository run without one. + +Report absence rather than a clean result. "No conflicts with curated knowledge" +is literally true against an empty corpus and tells the reader nothing, while +reading exactly like a completed check. Record instead that no curated concept +covers this work, that the contract is therefore unaligned by absence rather +than by verification, and what the alignment rested on instead — pinned source, +tests, maintainer statements. The same applies to a populated corpus that simply +has nothing about this Area: coverage is per-subject, not per-repository. + +Recommend a reconstruction when the gap is material and say what it would +establish, then proceed if the maintainer declines. It is a recommendation, and +it never becomes a precondition for doing the work. + +## Conflicts + +- `raw/` is neither evidence nor current truth. It is an untrusted clue source + used only through `process-raw-intake`. +- A later timestamp does not automatically make a source authoritative. +- `status: stable` is valid only with a matching current content hash; normative + claims additionally require human verification. +- A timestamp without a matching content hash does not prove the current text + was reviewed. +- When sources or code disagree and the correct intent cannot be established, ask the maintainer. +- Preserve unresolved uncertainty explicitly. Do not create a spec that silently selects one interpretation. diff --git a/.agents/skills/align-project-knowledge/agents/openai.yaml b/.agents/skills/align-project-knowledge/agents/openai.yaml new file mode 100644 index 0000000..df0a442 --- /dev/null +++ b/.agents/skills/align-project-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Align Project Knowledge" + short_description: "Align shaping with Areas and current truth" + default_prompt: "Use $align-project-knowledge to align this shaping record with relevant Areas, decisions, architecture, and current curated truth." diff --git a/.agents/skills/analyze-with-graphify/SKILL.md b/.agents/skills/analyze-with-graphify/SKILL.md new file mode 100644 index 0000000..c728404 --- /dev/null +++ b/.agents/skills/analyze-with-graphify/SKILL.md @@ -0,0 +1,75 @@ +--- +name: analyze-with-graphify +description: Mandatory Graphify routing gate for every source-code-dependent task. Use before locating, reading, explaining, planning, changing, debugging, reviewing, or verifying code; tracing callers, dependencies, data or control flow; estimating impact; or checking an implementation claim against a repository, even when the user does not mention Graphify. Check the current session for the official native graphify skill and any more specific Graphify skills, invoke them first, then inspect the actual source. Do not use Graphify as the primary parser, search engine, or authority for raw Markdown or curated knowledge files. +--- + +# Require Graphify First + +This skill owns mandatory workflow routing. The official native `graphify` +skill supplied by Graphify owns tool-specific execution. + +## Mandatory trigger + +Invoke this skill before any source-code-dependent understanding or change, +including implementation, investigation, planning, review, verification, +debugging, navigation, impact analysis, architecture work, and verification of +knowledge claims against code. Do not wait for the maintainer to mention +Graphify. + +Do not invoke it merely to read or reconcile Markdown, specs, raw intake, +curated concepts, prose documentation, or workflow configuration. Those use +QMD retrieval plus direct reading and their native structure. Invoke Graphify +when that work crosses the boundary into a source repository. + +## Session skill gate + +1. Inspect the skills exposed in the current session before source-code + navigation. +2. Require the official native skill named `graphify`, or a provider-namespaced + equivalent whose metadata identifies the official Graphify skill. Do not + count this `analyze-with-graphify` routing skill as the native skill. +3. Identify any additional Graphify-specific skills in the session and invoke + the most specific relevant one after loading the native skill. +4. Treat the current session catalog as authoritative. A skill file present on + disk may require an agent restart before it becomes active. +5. Invoke the native `graphify` skill and follow its complete procedure before + continuing the project task. + +If the native skill is absent, stop repository analysis: + +- If the `graphify` CLI is also absent, offer: + + ```sh + uv tool install graphifyy + graphify install --platform + ``` + +- If the CLI exists, offer only the matching + `graphify install --platform ` command. Use `graphify install --help` + to resolve the current platform name instead of guessing it. +- Tell the maintainer to restart the agent session after installation. +- Do not install user-level tooling without authority and do not silently fall + back to a partial hand-written Graphify procedure. + +## Project obligations + +1. Use the native skill to query an existing `graphify-out/graph.json` + immediately or build/update the graph when required. +2. Trace source-code relationships through the graph before drawing + conclusions. +3. Open and inspect the actual source locations returned by Graphify. The + source at the recorded Git revision, not the graph, is implementation + authority. +4. Only then use `rg` or equivalent text search for exact tokens, literals, + generated artifacts, or gaps not represented in the graph. +5. Record relevant queries, paths, and source locations in the active spec or + curation record. + +## Honesty + +- Distinguish extracted edges from inferred or ambiguous edges. +- Do not turn a missing graph result into proof that code does not exist. +- State when the graph is stale and update it before relying on changed sources. +- Do not substitute grep output for relationship analysis. +- Do not cite `graphify-out/` as proof in curated knowledge. Cite pinned source + locations and fresh checks reached through Graphify. diff --git a/.agents/skills/analyze-with-graphify/agents/openai.yaml b/.agents/skills/analyze-with-graphify/agents/openai.yaml new file mode 100644 index 0000000..c3ec6d8 --- /dev/null +++ b/.agents/skills/analyze-with-graphify/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Require Graphify First" + short_description: "Mandatory Graphify gate for source code" + default_prompt: "Use $analyze-with-graphify before any source-code-dependent analysis or change, confirm the native Graphify skills are active, invoke them first, and verify results in the actual source." diff --git a/.agents/skills/curate-engineering-knowledge/SKILL.md b/.agents/skills/curate-engineering-knowledge/SKILL.md new file mode 100644 index 0000000..7f4a82a --- /dev/null +++ b/.agents/skills/curate-engineering-knowledge/SKILL.md @@ -0,0 +1,55 @@ +--- +name: curate-engineering-knowledge +description: Author or materially update engineering-facing current knowledge from verified implementation and reviewed product authority. Use when a completed change, source-first reconstruction, or explicit implementation-drift repair is ready to record architecture, repository ownership, contracts, data or control flow, runtime behavior, operations, or technical constraints. Do not use merely because an engineer asks how existing code works; answer that read-only question through knowledge navigation and Graphify. Keep product meaning in linked product documents and never infer intended behavior from code alone. +--- + +# Curate Engineering Knowledge + +Write the technical realization of current project truth without duplicating or +silently redefining product meaning. + +Read [the engineering writing contract](references/engineering-writing-contract.md) +before first-time promotion. Use +[the engineering concept template](assets/engineering-concept.md) for a new +document. + +## Establish the implementation + +1. Identify the owning Area, product concepts, repository, and exact clean + source revision. +2. Invoke `analyze-with-graphify` for navigation and relationship coverage. +3. Directly inspect source, tests, contracts, configuration, and runtime + evidence at the pinned revision. +4. Distinguish implemented behavior, architectural rationale, ownership, + contract, policy, history, and external claims. Apply the authority required + by each class. +5. Treat code as implementation authority only. Link accepted product meaning; + never derive it from code. + +## Author the engineering view + +1. Declare `view: engineering`, `purpose: technical-realization`, and include + `engineer` or `operator` in `audience`. +2. Explain responsibility, current implementation, ownership boundaries, data + and control flow, contracts and invariants, failure behavior, operations, + and verification. +3. Name exact code surfaces only when they help maintenance or verification. + Pin material claims to repository, commit, path, and optional symbol. +4. Link the product concept that gives the implementation meaning. Keep + product behavior in that product document and describe only the technical + consequence here. +5. Record partial, absent, accidental, retired, unknown, or drifted delivery + honestly. Do not repair intent by rewriting it to match code. +6. Keep cross-Area architecture at `knowledge/architecture/`, repository + ownership at `knowledge/repositories/`, and Area-owned implementation at + `knowledge/areas//implementation/`. + +## Verify before stable + +1. Invoke `verify-knowledge-quality` after the body and evidence are complete. +2. Resolve gaps in source coverage, product linkage, failure behavior, and + claims that exceed their evidence. +3. Run `wfctl knowledge hash --concept ` and bind the quality receipt and + normal verification to that hash. +4. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. +5. Do not report completion while any gate fails. diff --git a/.agents/skills/curate-engineering-knowledge/agents/openai.yaml b/.agents/skills/curate-engineering-knowledge/agents/openai.yaml new file mode 100644 index 0000000..1f6d445 --- /dev/null +++ b/.agents/skills/curate-engineering-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Curate Engineering Knowledge" + short_description: "Write verified technical realization knowledge" + default_prompt: "Use $curate-engineering-knowledge to document verified implementation and architecture without duplicating product meaning." diff --git a/.agents/skills/curate-engineering-knowledge/assets/engineering-concept.md b/.agents/skills/curate-engineering-knowledge/assets/engineering-concept.md new file mode 100644 index 0000000..dc2e4d3 --- /dev/null +++ b/.agents/skills/curate-engineering-knowledge/assets/engineering-concept.md @@ -0,0 +1,76 @@ +--- +type: "" +title: "" +description: "" +status: draft +view: engineering +purpose: technical-realization +audience: + - engineer + - operator + - maintainer +area: "" +capabilities: [] +authority: + - implementation +generated: + by: "/" + at: "" +verified: [] +x-wf: + relations: [] + quality: + status: pending +sources: + - id: "" + kind: source-code + resource: "git:@<40-character-commit>#[:]" + title: "" +--- + +# Responsibility + +State the technical responsibility and its maintenance boundary.[^source-id] + +# Current implementation + +Explain how the responsibility is implemented at the pinned revision. + +# Boundaries and ownership + +Name repositories, components, dependencies, and ownership transitions. + +# Data and control flow + +Trace material inputs, state changes, outputs, and asynchronous boundaries. + +# Contracts and invariants + +State interfaces, schemas, protocols, invariants, and compatibility constraints. + +# Failure and operational behavior + +Explain failure modes, recovery, observability, security, and operational +constraints, or state why a concern does not apply. + +# Verification + +Link the source, tests, runtime checks, and limitations that support this +document. + +# Product knowledge + +Link the stakeholder-facing product concepts that explain why this +implementation exists. Do not duplicate their product explanation. + +# Relationships + +Link the parent Area and related engineering concepts. Mirror material +semantic links in `x-wf.relations`. + +[^source-id]: Direct pinned source evidence. + +Before changing `status` to `stable`, invoke `verify-knowledge-quality`, then +run `wfctl knowledge hash --concept knowledge/.../.md`. Replace +`x-wf.quality` with a passed receipt containing `by`, `at`, `content_hash`, and +all required checks. Add a current `verified` event with the same hash. diff --git a/.agents/skills/curate-engineering-knowledge/references/engineering-writing-contract.md b/.agents/skills/curate-engineering-knowledge/references/engineering-writing-contract.md new file mode 100644 index 0000000..2488d36 --- /dev/null +++ b/.agents/skills/curate-engineering-knowledge/references/engineering-writing-contract.md @@ -0,0 +1,51 @@ +# Engineering writing contract + +## Reader contract + +Write for engineers and operators who already understand the product concept +or can follow its link. Let them answer: + +1. What technical responsibility does this surface own? +2. Where and how is it implemented at the pinned revision? +3. How do data and control move through it? +4. Which contracts, invariants, and ownership boundaries apply? +5. How does it fail and how is it operated? +6. What evidence verifies the claims? + +## Separation rules + +- Link product meaning; do not restate it as technical prose. +- Never infer accepted intent, correctness, or rationale from code alone. +- Keep repository and symbol details out of product documents. +- Keep implementation detail here only when it helps understand, change, + operate, or verify the system. +- State uncertainty and drift explicitly. +- Separate current implementation from historical implementation and rejected + alternatives. + +## Required sections + +- `Responsibility` +- `Current implementation` +- `Boundaries and ownership` +- `Data and control flow` +- `Contracts and invariants` +- `Failure and operational behavior` +- `Verification` +- `Product knowledge` +- `Relationships` + +Use `Not applicable` with a reason when a section genuinely does not apply. + +## Method basis + +- C4 uses explicit abstraction levels for different audiences: + https://c4model.com/introduction +- arc42 separates stakeholder goals from hierarchical technical building + blocks: + https://docs.arc42.org/section-1/ +- Spec Kit separates product what/why from implementation how: + https://github.github.com/spec-kit/reference/agentic-sdd.html +- Architecture Decision Records preserve rationale and supersession separately + from current implementation: + https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions diff --git a/.agents/skills/curate-product-knowledge/SKILL.md b/.agents/skills/curate-product-knowledge/SKILL.md new file mode 100644 index 0000000..1753516 --- /dev/null +++ b/.agents/skills/curate-product-knowledge/SKILL.md @@ -0,0 +1,83 @@ +--- +name: curate-product-knowledge +description: Author or materially update stakeholder-facing current product knowledge after its claims have independent authority. Use when an approved change, reviewed reconstruction, confirmed intake candidate, source audit, or explicit maintainer decision is ready to create or correct an Area, capability, use case, product flow, domain concept, product rule, vision statement, delivery summary, or evolution summary. Do not use for ordinary explanation, discovery, brainstorming, review-only requests, or unverified raw ideas. Separate accepted intent from observed delivery and keep implementation details in linked engineering documents. +--- + +# Curate Product Knowledge + +Write the product view of verified project truth. Make it understandable to a +client or product manager without requiring source code, API, architecture, or +repository knowledge. + +Read [the product writing contract](references/product-writing-contract.md) +before authoring or materially rewriting a product document. Use +[the product concept template](assets/product-concept.md) for a new concept. + +## Establish authority + +1. Work from the knowledge root and identify the primary Area. +2. Read the relevant Area index, product concepts, current decisions, and + linked engineering concepts in full. +3. Separate: + - accepted intent and product meaning; + - currently observed delivery; + - alignment or drift between them; + - planned, rejected, superseded, and unknown claims. +4. Require explicit maintainer authority for intent, product meaning, rules, + normative ownership, and product decisions. +5. Require pinned source and fresh checks for delivery claims. Invoke + `analyze-with-graphify` in every relevant exact leaf before direct source + and test inspection. +6. Never use raw, intake, search results, a compiled graph, or agent prose as + authority. + +## Author the product view + +1. Declare `view: product`, `purpose: current-behavior`, and include + `stakeholder` in `audience`. +2. State the current answer first. Explain what the product provides, who it + serves, observable behavior, rules, outcomes, boundaries, exceptions, + delivery state, examples, and meaningful evolution. +3. Use the vocabulary a domain expert or client would use. Explain necessary + domain terms on first use. + For a `Domain Concept`, explicitly record the canonical term, concise + definition, contextual boundary, accepted aliases, and names to avoid. + During unresolved discussion, keep proposed terms in the active change + record rather than silently changing current vocabulary. +4. Describe outcomes and behavior, not classes, functions, endpoints, schemas, + storage, messages, packages, repositories, or source paths. +5. Keep `Engineering details` link-only. Put technical explanations in a + document authored with `curate-engineering-knowledge`. +6. Use present tense only for behavior supported by the declared delivery + state. Say plainly when a capability is absent, partial, retired, unknown, + or accepted but not yet available. Never present planned or uncertain + behavior as currently available. +7. Preserve material exceptions and conditions. Plain language may simplify + wording but must not simplify away meaning. +8. Keep current truth at one stable path. When a decision changes, update the + current product explanation and link the decision lineage; do not copy the + whole Area into version folders. +9. Attribute every material claim to an authoritative source with matching + source IDs and footnotes. Do not expose machine-local paths. + +## Area indexes + +Treat `knowledge/areas//index.md` as the primary stakeholder page for an +Area. Use the Area template owned by `curate-project-knowledge`. Keep it +product-first and bounded: + +- summarize rather than flatten every child document; +- link capabilities, use cases, rules, current decisions, and evolution; +- show delivery honestly; +- keep `Engineering details` as links with short nontechnical labels. + +## Verify before stable + +1. Invoke `verify-knowledge-quality` after the substantive body is complete. +2. Resolve every failed or uncertain rubric item. +3. Run `wfctl knowledge hash --concept ` and bind both the semantic + quality receipt and normal verification to that content hash. +4. Use `status: stable` only after the quality receipt is current, all + authority requirements pass, and normative claims have human verification. +5. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. +6. Do not report completion while any gate fails. diff --git a/.agents/skills/curate-product-knowledge/agents/openai.yaml b/.agents/skills/curate-product-knowledge/agents/openai.yaml new file mode 100644 index 0000000..0a53d11 --- /dev/null +++ b/.agents/skills/curate-product-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Curate Product Knowledge" + short_description: "Write verified stakeholder-facing product knowledge" + default_prompt: "Use $curate-product-knowledge to author verified current product behavior and canonical domain language for nontechnical stakeholders." diff --git a/.agents/skills/curate-product-knowledge/assets/product-concept.md b/.agents/skills/curate-product-knowledge/assets/product-concept.md new file mode 100644 index 0000000..d2fc09b --- /dev/null +++ b/.agents/skills/curate-product-knowledge/assets/product-concept.md @@ -0,0 +1,99 @@ +--- +type: "" +title: "" +description: "" +status: draft +view: product +purpose: current-behavior +audience: + - stakeholder + - maintainer + - domain-expert +area: "" +capabilities: [] +authority: + - product-meaning + - implementation +generated: + by: "/" + at: "" +verified: [] +realization: + intent: "" + delivery: "" + alignment: "" + assessed_at: "" +x-wf: + relations: [] + quality: + status: pending +sources: + - id: "" + kind: maintainer-decision + resource: "" + title: "" + author: "human:" + - id: "" + kind: source-code + resource: "git:@<40-character-commit>#[:]" + title: "" +--- + +# What this provides + +Explain the recognizable product outcome and why it matters.[^maintainer-source-id] + +# Who it serves + +Name the people, roles, or neighboring capabilities that rely on it. + +# Domain language + +Define terms introduced or owned by this concept. For a `Domain Concept`, +state the canonical term, its contextual boundary, accepted aliases, and names +to avoid. Otherwise state `No new terms` when the document introduces none. + +# Current behavior + +Explain what happens now in observable product terms. Keep accepted intent and +observed delivery distinct when they differ.[^delivery-source-id] + +# Rules and outcomes + +State the decisions, state changes, and outcomes that govern the behavior. + +# Boundaries and exceptions + +Preserve material limits, conditions, exceptions, and explicit non-goals. + +# Delivery + +Say plainly whether the behavior is available, partial, absent, retired, or +uncertain, and what that means for the reader. + +# Examples + +Give one or more concrete domain examples. Do not use code or API examples. + +# Evolution + +Summarize only meaningful changes needed to understand the current behavior. +Link full decision records for rationale and lineage. + +# Related knowledge + +Link the parent Area, related capabilities, rules, use cases, flows, and +current decisions. Mirror material semantic links in `x-wf.relations`. + +# Engineering details + +Link separately authored engineering documents. Do not summarize their +implementation here. + +[^maintainer-source-id]: Explicit reviewed product authority. +[^delivery-source-id]: Direct pinned evidence for current delivery. + +Before changing `status` to `stable`, invoke `verify-knowledge-quality`, then +run `wfctl knowledge hash --concept knowledge/.../.md`. Replace +`x-wf.quality` with a passed receipt containing `by`, `at`, `content_hash`, and +all required checks. Add a current `verified` event with the same hash. diff --git a/.agents/skills/curate-product-knowledge/references/product-writing-contract.md b/.agents/skills/curate-product-knowledge/references/product-writing-contract.md new file mode 100644 index 0000000..c05d9d0 --- /dev/null +++ b/.agents/skills/curate-product-knowledge/references/product-writing-contract.md @@ -0,0 +1,87 @@ +# Product writing contract + +## Reader contract + +Write for a product manager, client, maintainer, or domain expert who wants to +understand what the product does without learning how the software is built. +The reader should be able to answer: + +1. What does this provide and why does it matter? +2. Who uses or depends on it? +3. Which domain terms does it own, and what do they mean here? +4. What happens now in observable product terms? +5. Which rules, outcomes, boundaries, and exceptions apply? +6. Is it available, partial, absent, retired, or uncertain? +7. What changed materially and where can the rationale be found? + +This is neither end-user help nor a PRD. It is the stakeholder view of current, +verified product knowledge. Proposed behavior stays in active change records. + +## Language rules + +- Lead with the current answer. +- Prefer short concrete sentences and active voice. +- Use domain language; define necessary terms on first use. +- Reuse canonical Area terminology. Record aliases and discourage overloaded + names rather than silently switching vocabulary. +- Describe outcomes, choices, state changes, and visible consequences. +- Preserve conditions, exceptions, and non-goals. +- Replace internal identifiers with human names. +- Do not include code fences, inline code, API routes, source paths, data + schemas, class or function names, storage mechanisms, package names, or + protocol details. +- Do not say "the system" when the specific product actor or capability is + known. +- Do not call planned behavior current. Pair every present-tense delivery claim + with evidence and an explicit realization state. + +## Required sections + +- `What this provides` +- `Who it serves` +- `Domain language` +- `Current behavior` +- `Rules and outcomes` +- `Boundaries and exceptions` +- `Delivery` +- `Examples` +- `Evolution` +- `Related knowledge` +- `Engineering details` + +Use `Not applicable` with a short reason rather than deleting a section whose +absence could hide an unexamined concern. `Engineering details` contains links +only; it never summarizes implementation. + +## Abstraction test + +Fail the product view when any of these are true: + +- a stakeholder needs engineering knowledge to understand the main answer; +- replacing an implementation would require rewriting product behavior even + though the behavior did not change; +- technical nouns outnumber product or domain nouns; +- an important exception disappeared during simplification; +- the text implies delivery that the realization state or evidence does not + support; +- history overwhelms the current answer; +- a raw candidate or agent inference appears as authority. + +## Method basis + +The contract combines established boundaries rather than inventing one prose +style: + +- Diátaxis separates documentation by reader need: + https://diataxis.fr/ +- Spec Kit keeps product specification focused on what and why, with technical + how in a separate plan: + https://github.github.com/spec-kit/reference/agentic-sdd.html +- GOV.UK Content Design starts from user need and plain language: + https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/plan-manage-content/understand-content-design/ +- W3C clear-content guidance requires understandable language and structure: + https://www.w3.org/WAI/WCAG2/supplemental/objectives/o3-clear-content/ +- Cucumber BDD uses concrete examples to align business and technical meaning: + https://cucumber.io/docs/bdd/ +- Domain Storytelling validates domain behavior with domain experts: + https://domainstorytelling.org/ diff --git a/.agents/skills/curate-project-knowledge/SKILL.md b/.agents/skills/curate-project-knowledge/SKILL.md new file mode 100644 index 0000000..50efa67 --- /dev/null +++ b/.agents/skills/curate-project-knowledge/SKILL.md @@ -0,0 +1,110 @@ +--- +name: curate-project-knowledge +description: Orchestrate promotion of independently verified claims into a workflow project's current OKF knowledge bundle. Use when a completed change, reconstruction case, raw-intake candidate, source audit, external source, or maintainer decision is ready to update durable product knowledge, engineering knowledge, decision lineage, Area navigation, or project history. Route stakeholder-facing content to curate-product-knowledge, technical realization to curate-engineering-knowledge, and every material document through verify-knowledge-quality. Never copy raw input into knowledge or let code define product intent. +--- + +# Curate Project Knowledge + +Coordinate the promotion boundary. Do not author product and engineering views +as one blended document. + +Read [the knowledge model](references/knowledge-model.md) before first-time +promotion, a new Area, or decision migration. + +## Accepted inputs + +A promotion may start from: + +- a completed and maintainer-reviewed archived change; +- confirmed raw-intake candidate IDs with independent authority; +- confirmed source-first reconstruction candidate IDs; +- directly inspected source and tests at an exact Git revision; +- a primary external source; +- an explicit current maintainer decision. + +`raw/`, intake prose, QMD results, compiled graphs, Graphify output, and +agent-written summaries are never authority. + +## Route each durable claim + +Classify claims before selecting files: + +| Durable concern | View and owner | +| --- | --- | +| Current product purpose, capability, use case, flow, domain concept, rule, delivery summary, or Area evolution | Invoke `curate-product-knowledge` | +| Current implementation, architecture, repository ownership, contract, data/control flow, runtime, or operations | Invoke `curate-engineering-knowledge` | +| Durable choice that is hard to reverse, surprising without context, or resolves a real tradeoff | Use the decision template, then invoke `verify-knowledge-quality` | +| Primary external context | Use `view: reference`, preserve the primary source, then verify | +| Trusted unresolved current question | Use `view: uncertainty`, state missing authority, then verify | +| Proposed, rejected, or unadopted behavior | Keep outside `knowledge/` in changes, intake, or reconstruction | + +A significant product change normally updates both a product concept and its +linked engineering concept. A refactor with unchanged product behavior may +update engineering knowledge only. Do not create an empty counterpart merely +for symmetry. + +## Promotion procedure + +1. Work from the knowledge root. Require and invoke the native QMD skill, use + QMD to locate candidates, and read every selected document directly. +2. Identify the smallest primary Area. Use root product flows, architecture, + repositories, or decisions only when ownership genuinely crosses Areas. +3. Inspect existing lifecycle, provenance, realization, quality receipt, + verification, and decision lineage. +4. When implementation matters, invoke `analyze-with-graphify` in each exact + leaf, then inspect pinned source, tests, contracts, and runtime evidence. +5. Separate accepted intent, observed delivery, alignment, technical + realization, decision history, and uncertainty. Ask the maintainer only for + missing product authority, chronology, ownership, or a material decision. +6. Route product and engineering documents to their specialized skills. Never + reuse one body for both audiences. +7. For a new Area, create its product-facing index from + [the Area template](assets/area-index.md). Add only the typed sibling + collections needed now: `capabilities/`, `use-cases/`, `concepts/`, + `rules/`, `implementation/`, `decisions/`, and `log.md`. +8. Give every concept explicit `view`, `purpose`, and `audience`. Attribute + every material claim with an authoritative source ID and matching footnote. +9. Declare only material semantic edges in `x-wf.relations`; include a + meaningful context and a matching human-visible Markdown link. +10. Create a standalone decision only when the choice is hard to reverse, + surprising without context, or resolves a real tradeoff. Routine + implementation choices and minor wording changes belong in the owning + concept, change ledger, or Area evolution. Author durable decisions from + [the decision template](assets/decision.md). Keep one stable current + decision per lineage; make supersession reciprocal and acyclic. Preserve + approved predecessor bodies. +11. Update the product-facing Area index and its Evolution section when + current behavior changes. Append detailed chronology to the local log. +12. Invoke `verify-knowledge-quality` for every new or materially changed + concept. Do not self-approve a failed, uncertain, unread, or blocked check. +13. Finish content before hashing. Bind the passed quality receipt and normal + verification to the same `wfctl knowledge hash --concept ` output. + Normative claims require human verification. +14. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. + Rebuild embeddings only when semantic retrieval is needed. +15. Return to the originating intake, reconstruction, or change workflow and + record exact promoted paths. Do not report completion while any gate fails. + +## Authority rules + +- Product intent, meaning, rules, and normative decisions require explicit + maintainer authority. +- Existing implementation requires pinned source and direct inspection. + Runtime claims require a fresh receipt when static code is insufficient. +- An absent delivery claim may use a reviewed whole-scope reconstruction + receipt because nonexistent code cannot be pinned. +- Architectural rationale, ownership, contracts, and policy require + maintainer review and contradiction checks against current implementation. +- Historical implementation requires pinned version-control history plus a + reviewed archive or reconstruction receipt. +- External facts require primary sources. + +A rejected proposal remains case-only by default. When repeated proposals +reveal a durable product boundary, ask the maintainer whether the boundary +itself is an accepted non-goal or negative rule. Only that explicitly accepted +rule may become current product knowledge or a decision; the rejected proposal +and its raw origin still remain outside the trust boundary. + +If a concept mixes authority classes, attribute each material claim to the +correct source. A quality receipt checks the writing and evidence match; it +does not create authority. diff --git a/.agents/skills/curate-project-knowledge/agents/openai.yaml b/.agents/skills/curate-project-knowledge/agents/openai.yaml new file mode 100644 index 0000000..6ad5907 --- /dev/null +++ b/.agents/skills/curate-project-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Curate Project Knowledge" + short_description: "Route verified claims into separate knowledge views" + default_prompt: "Use $curate-project-knowledge to route verified claims into product, engineering, and decision knowledge with quality review." diff --git a/.agents/skills/curate-project-knowledge/assets/area-index.md b/.agents/skills/curate-project-knowledge/assets/area-index.md new file mode 100644 index 0000000..86c1c9b --- /dev/null +++ b/.agents/skills/curate-project-knowledge/assets/area-index.md @@ -0,0 +1,61 @@ +# + +## Purpose + +Explain the recognizable responsibility or product functionality this Area +provides and why it exists. + +## Who it serves + +Name the people, roles, and neighboring Areas that depend on it. + +## Scope and boundaries + +State what belongs here, what does not, and the important boundaries with +neighboring Areas. + +## Current product behavior + +Summarize current observable behavior in stakeholder language. Keep accepted +intent, available delivery, partial or absent delivery, and known drift +distinct. Link detailed concepts instead of flattening them here. + +## Capabilities + +- Link each capability and state the product outcome it provides. + +## Use cases and flows + +- Link Area-local use cases. +- Link genuinely cross-Area flows from `knowledge/product/flows/`. + +## Rules and outcomes + +- Link material product rules and summarize only the outcome or constraint a + stakeholder needs to understand. + +## Delivery overview + +Summarize which accepted capabilities are available, partial, absent, retired, +or uncertain. Do not present implementation as accepted intent by default. + +## Current decisions + +- Link the stable current decision for each active Area-owned lineage. +- Reach deprecated predecessors through the current decision or Evolution. + +## Evolution + +Explain meaningful changes with enough context to understand what changed, +why, and what it affected. Link full decision records and the local `log.md`; +do not reduce history to bare supersession links. + +## Open questions + +- Link trusted current uncertainties. Raw candidates do not belong here. + +## Engineering details + +- Link Area-owned engineering concepts from `implementation/`. +- Link system-wide architecture and repository ownership where relevant. +- Keep this section link-only; technical explanations live in those documents. diff --git a/.agents/skills/curate-project-knowledge/assets/decision.md b/.agents/skills/curate-project-knowledge/assets/decision.md new file mode 100644 index 0000000..390516e --- /dev/null +++ b/.agents/skills/curate-project-knowledge/assets/decision.md @@ -0,0 +1,86 @@ +--- +type: Decision +title: "" +description: "" +status: draft +view: decision +purpose: decision-history +audience: + - maintainer + - domain-expert + - engineer +decision_id: "" +effective_at: "" +area: "" +capabilities: [] +authority: + - decision +generated: + by: "/" + at: "" +verified: [] +supersedes: [] +superseded_by: "" +x-wf: + relations: [] + quality: + status: pending +sources: + - id: "" + kind: maintainer-decision + resource: "project-change:#decision" + title: "" + author: "human:" +--- + +# Context and problem + +Explain only the context needed to understand why this durable decision +exists.[^maintainer-decision-id] + +# Product decision + +State the exact stakeholder-visible choice and its boundaries in plain +language. + +# Rationale + +Explain why this option was chosen. + +# Alternatives + +List only alternatives that were seriously considered and why they were not +selected. State `None recorded` rather than inventing options. + +# Consequences and tradeoffs + +State benefits, costs, risks, constraints, and accepted tradeoffs. Link +engineering knowledge for technical consequences instead of embedding an +implementation walkthrough. + +# Affected knowledge + +Link every materially affected Area, capability, use case, rule, engineering +concept, or contract. Record non-lineage semantic edges in `x-wf.relations` +with the same target and meaningful context. + +# Transition and migration + +Explain the product transition and link separate engineering migration detail, +or state `Not applicable`. + +# Unresolved questions + +List only questions left open by this decision, or state `None`. + +# Evolution + +Explain what this changes relative to each predecessor. Use project-relative +`knowledge/...` paths in `supersedes` and `superseded_by`; keep reciprocal +links accurate and include matching links to every predecessor and successor. + +[^maintainer-decision-id]: Explicit maintainer approval in the linked decision receipt. + +Before changing `status` to `stable`, invoke `verify-knowledge-quality`, run +`wfctl knowledge hash --concept knowledge/.../.md`, and bind both +the passed quality receipt and human verification to the returned hash. diff --git a/.agents/skills/curate-project-knowledge/references/knowledge-model.md b/.agents/skills/curate-project-knowledge/references/knowledge-model.md new file mode 100644 index 0000000..bbffae7 --- /dev/null +++ b/.agents/skills/curate-project-knowledge/references/knowledge-model.md @@ -0,0 +1,247 @@ +# Knowledge model + +## Surfaces and trust + +- `raw/`: append-oriented untrusted input; never evidence. +- `intake/`: Git-frozen raw review records; never current truth or an OKF source. +- `reconstruction/`: source-first baseline and audit receipts at exact revisions. +- `changes/active/`: proposed behavior, living execution agreements, and their checkpoints. +- `changes/archive/`: qualified historical changes and resolved capture receipts. +- `changes/inbox/`: pending non-authoritative captures awaiting triage. +- `knowledge/`: curated OKF v0.2 current knowledge and the default reading surface. +- source repositories: implementation authority at exact revisions. + +QMD retrieves Markdown but never proves truth or coverage. Compiled graphs are +disposable navigation. Graphify navigates source code but is not authority. +Every selected source is read directly. + +All lanes converge through one promotion gate: extract atomic candidates, +verify each against its proper authority, obtain maintainer adjudication for +normative or ambiguous meaning, update the smallest coherent current concepts, +verify quality, and validate the bundle. + +## One truth, multiple views + +Product and engineering documents are linked views of the same project, not +independent truths. + +### Product view + +Use for vision, Areas, capabilities, use cases, cross-Area product flows, +domain concepts, product rules, delivery summaries, and stakeholder evolution. +It answers what the product provides, who it serves, how it behaves now, which +rules and exceptions apply, and whether it is available. + +Declare: + +```yaml +view: product +purpose: current-behavior +audience: [stakeholder, maintainer, domain-expert] +``` + +Product bodies contain no implementation detail. Their `Engineering details` +section contains links only. + +### Engineering view + +Use for implementation, architecture, repositories, contracts, data and +control flow, runtime behavior, operations, and technical constraints. It +answers how current product behavior is realized and verified. + +Declare: + +```yaml +view: engineering +purpose: technical-realization +audience: [engineer, operator, maintainer] +``` + +Engineering documents link product meaning and never infer accepted intent +from code. + +### Decision, reference, and uncertainty views + +- Decisions use `view: decision`, `purpose: decision-history`, and include the + maintainer audience. +- Primary external context uses `view: reference`, + `purpose: external-context`. +- Trusted live questions use `view: uncertainty`, `purpose: open-question`. + +Proposed or rejected ideas do not use a current knowledge view. + +Create a standalone decision only when the choice is hard to reverse, +surprising without context, or resolves a real tradeoff. Keep routine local +choices in the owning concept, change ledger, or Area evolution. A repeated +rejection may expose a durable non-goal, but only an explicit maintainer +decision promotes that negative rule; rejected proposals remain case-only. + +## Human information architecture + +- `knowledge/index.md`: progressive project entry point. +- `vision/`: accepted project purpose, outcomes, principles, and non-goals. +- `areas/`: primary durable product or functional decomposition. +- `product/`: concise users, outcomes, Areas, and genuinely cross-Area flows. +- `architecture/`: cross-Area technical realization. +- `decisions/`: only genuinely cross-Area decision records. +- `repositories/`: technical ownership and integration boundaries. +- `uncertainties/`: trusted unresolved current questions. +- `references/`: primary external context. + +Each `areas//index.md` is the main stakeholder page. It links typed +sibling collections: + +```text +areas// +├── index.md +├── capabilities/ # product +├── use-cases/ # product +├── concepts/ # product/domain +├── rules/ # product +├── implementation/ # engineering +├── decisions/ # decision history +└── log.md # local chronology +``` + +Do not nest implementation and decisions under a capability merely because +they support it. Link them. Subdivide a typed collection only when its own +size requires it. + +Use root collections only for honest project-wide ownership. When one Area is +primary, store the artifact there and link it from affected Areas. A bounded +context is a proven technical model and language boundary, not another word +for Area. + +## Product intent and realization + +Document lifecycle and product delivery are independent: + +```yaml +realization: + intent: accepted + delivery: verified + alignment: aligned + assessed_at: 2026-07-28T12:00:00Z +``` + +- Curated intent is `accepted` or `superseded`. +- Delivery is `absent`, `partial`, `implemented`, `verified`, `retired`, + `unknown`, or `not-applicable`. +- Alignment is `aligned`, `drifted`, `unknown`, or `not-applicable`. + +Concrete delivery requires implementation authority. Concrete alignment +requires both product and implementation authority. Code proves observed +delivery, never accepted intent or correctness. + +## Authored relations and compiled navigation + +Every concept declares `x-wf.relations`. Add only material relations, give +each a meaningful context, and repeat its target as a normal Markdown link. +Supported kinds are `supports`, `governed-by`, `implemented-by`, `depends-on`, +`affects`, `conflicts-with`, and `related-to`. + +Area ownership and decision lineage have dedicated metadata and generated +edges. Stable concepts remain reachable from `knowledge/index.md`. +`wfctl knowledge build` compiles these explicit statements into ignored +navigation artifacts; it infers no truth. + +## Strict profile over OKF + +Every concept requires: + +- explicit lifecycle, view, purpose, audience, generation, and authority; +- non-empty claim-level authoritative sources with matching footnotes; +- explicit authored relations and valid human-visible links; +- current verification for stable content; +- a current semantic quality receipt for stable content; +- human verification for normative authority; +- explicit deprecation destination or reason; +- no raw or intake reference. + +Path and view must agree: + +- product: `vision/`, `product/`, and Area `capabilities/`, `use-cases/`, + `concepts/`, or `rules/`; +- engineering: `architecture/`, `repositories/`, and Area `implementation/`; +- decision: root or Area `decisions/`; +- reference: `references/`; +- uncertainty: `uncertainties/`. + +The validator rejects code and implementation sections in product documents +and requires their stakeholder sections. It requires technical sections in +engineering documents. Deterministic checks cannot prove semantic truth, so +`verify-knowledge-quality` reads the full evidence and records a +content-hash-bound review. + +## Quality receipt + +After semantic review, record: + +```yaml +x-wf: + relations: [] + quality: + status: passed + by: workflow-agent/1 + at: 2026-07-28T12:00:00Z + content_hash: "" + checks: + - factuality + - audience-fit + - abstraction + - completeness + - delivery-state + axes: + authority-truth: + status: passed + by: workflow-agent/1 + at: 2026-07-28T12:00:00Z + content_hash: "" + reader-communication: + status: passed + by: workflow-agent/1 + at: 2026-07-28T12:00:00Z + content_hash: "" +``` + +The material hash excludes `verified` and `x-wf.quality`, allowing both +receipts to bind the exact authored content without self-reference. Any other +material edit changes the hash and invalidates both receipts. The quality +receipt records a review; it creates no authority. Its two axes review +authority/truth and reader communication independently. + +## Authority by claim + +- Intent, product meaning, architecture rationale, ownership, contracts, + policy, and decisions require maintainer authority. +- Existing implementation requires pinned source code. +- Absent delivery may use a reviewed whole-scope reconstruction receipt. +- History requires pinned version-control evidence plus a reviewed archived + change or reconstruction receipt. +- External facts require primary sources. + +Pinned code resources use +`git:@<40-character-commit>#[:]`. +Reviewed changes use `project-change:#
`. +Reviewed reconstruction decisions use +`project-reconstruction:#`. + +Authority is claim-specific. Repetition, recency, search rank, and agent +confidence do not create authority. + +## Current truth and evolution + +Keep current meaning at one stable path. A changed decision creates a successor +record and deprecates predecessors through reciprocal lineage. Do not version +whole Areas. + +The product Area index leads with current behavior. Its Evolution section +summarizes what changed, why, and what it affected. Full decision records keep +context, exact choice, rationale, alternatives, consequences, transition, open +questions, and lineage. Area logs provide local chronology without flattening +hundreds of decisions into one file. + +Canonical domain language belongs with the owning Area concept rather than a +flattened global glossary. Record the preferred term, definition, contextual +boundary, accepted aliases, and names to avoid. Proposed terminology stays in +the active change record until product authority accepts it. diff --git a/.agents/skills/explore-project-knowledge/SKILL.md b/.agents/skills/explore-project-knowledge/SKILL.md new file mode 100644 index 0000000..2ede90d --- /dev/null +++ b/.agents/skills/explore-project-knowledge/SKILL.md @@ -0,0 +1,95 @@ +--- +name: explore-project-knowledge +description: Guide a person through a project's current product knowledge without requiring them to know its Areas, capabilities, terminology, repository layout, document paths, or implementation. Use for broad newcomer questions such as what this project is, why it exists, what it can do today, what is accepted but unavailable or unknown, where to start, or what its main directions are; for follow-ups that explore one Area, capability, use case, flow, rule, delivery state, or product decision; and whenever a product manager, client, maintainer, or domain expert needs a progressive nontechnical explanation rather than knowledge authoring. Remain read-only, reveal detail gradually, and never turn a question into curation or source work without explicit need. +--- + +# Explore Project Knowledge + +Act as a product guide for someone who may not know what to ask yet. Discover +the available reading path yourself and reveal it gradually. + +Read [the exploration contract](references/exploration-contract.md) before the +first broad discovery request in a session. + +## Resolve the knowledge surface + +1. If the current repository has the `knowledge` profile, work from it. +2. If the current repository is a leaf, read `.workflow/config.json`, resolve + its configured knowledge repository, and answer from that repository. +3. Require and invoke the native QMD skill. Check `qmd status`, then search only + the `knowledge` collection. If the native skill, CLI, or project index is + unavailable, invoke `setup-workflow-environment`; do not substitute grep or + pretend discovery was complete. +4. Answer from the documents first. Do not run `wfctl check` or + `wfctl knowledge validate` merely to answer a question: exploration is the + least expensive path, and both commands are diagnostics, not reading. Run + `wfctl knowledge validate` only when a document you read looks internally + inconsistent, a link you need is broken, or the reader asks how trustworthy + the map is. When it does fail, give only the bounded trustworthy orientation + still supported by readable current documents, state that the map is + incomplete, and offer a separate repair or audit. +5. Start with `knowledge/index.md` and the reachable Area indexes. Use QMD and + `.workflow/current/knowledge-graph.json` to find candidate paths, then read + every selected Markdown document directly. The compiled graph is a + navigation cache: when a path it suggests does not exist, fall back to + direct reading rather than trusting the graph or rebuilding it mid-answer. +6. Treat curated knowledge as the answer surface, subject to its lifecycle, + provenance, verification, realization, and uncertainty. Retrieval results + and generated graphs are navigation, not evidence. + +## Choose the reader's current level + +- **Discovery:** The reader does not know the project. Explain its purpose, + intended audience, current product shape, major directions, delivery + overview, and important unknowns. +- **Area exploration:** Explain one product direction, the outcomes it owns, + major capabilities and flows, current delivery, governing rules, and useful + next branches. +- **Focused explanation:** Explain one capability, use case, flow, rule, or + product decision: current behavior first, then conditions, exceptions, + delivery, and meaningful evolution. +- **Technical or historical deep dive:** Hand routing back to + `operate-project-knowledge` so engineering realization or full decision + lineage remains a separately labeled answer. + +Do not force the reader to choose a level or use workflow terminology. Infer +the narrowest honest level from ordinary language and continue naturally. + +## Present progressive disclosure + +1. Lead with the current product answer, not file structure or methodology. +2. On discovery, present a compact project map rather than flattening every + concept. Group a large corpus into a manageable set of recognizable + directions. +3. For every item, distinguish verified/current, partial, accepted but absent, + retired, and unknown. Do not turn missing delivery into missing intent or + vice versa. Proposed plans are outside current knowledge; route an explicit + roadmap question through `operate-project-knowledge` and label it separately. +4. Use stakeholder and domain language. Explain an internal term before using + it as navigation. +5. End with three to five concrete follow-up directions derived from the + knowledge actually found. Let the reader choose what to open next. +6. Ask at most one question when a useful overview can still be given. Ask + earlier only when the request could refer to materially different projects + or product meanings. +7. Keep code, repositories, paths, schemas, APIs, workflow commands, source + metadata, and quality machinery out of the answer unless the reader asks. + +## Stay read-only + +- Do not create or edit knowledge, changes, intake, reconstruction, indexes, or + source code merely because the reader asked a question. +- Do not invoke curation or quality verification for an explanation alone. +- If current knowledge is missing, stale, contradictory, or too weak to answer, + explain the exact product-level limitation and offer the appropriate next + action: knowledge audit, source verification, reconstruction, raw intake, or + maintainer adjudication. +- Perform that action only when the user asks to continue or when their + original request explicitly required current verification or repair. +- If exploration exposes a likely documentation defect, report it separately; + do not silently repair it. + +When available, return control to `operate-project-knowledge` whenever the +request changes from understanding into auditing, verification, authoring, or +decision-making. From a leaf, follow the leaf workflow when the request becomes +implementation. diff --git a/.agents/skills/explore-project-knowledge/agents/openai.yaml b/.agents/skills/explore-project-knowledge/agents/openai.yaml new file mode 100644 index 0000000..6e08490 --- /dev/null +++ b/.agents/skills/explore-project-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Explore Project Knowledge" + short_description: "Guide people through current project knowledge" + default_prompt: "Use $explore-project-knowledge to help me understand this project from a product perspective." diff --git a/.agents/skills/explore-project-knowledge/references/exploration-contract.md b/.agents/skills/explore-project-knowledge/references/exploration-contract.md new file mode 100644 index 0000000..e383a7a --- /dev/null +++ b/.agents/skills/explore-project-knowledge/references/exploration-contract.md @@ -0,0 +1,77 @@ +# Product exploration contract + +## Reader assumption + +Assume the reader may know only that a project exists. They are not expected to +know its taxonomy, feature names, implementation, history, or the wording used +inside the knowledge base. + +The agent owns discovery. The reader owns curiosity and product authority. + +## Response levels + +### Discovery + +Answer: + +1. What is this project for? +2. Who benefits from it? +3. What are its major product directions? +4. What works today? +5. What is partial, accepted but absent, retired, or unknown? +6. Where could the reader go next? + +Keep the first response scannable. Prefer a short orientation plus three to +five meaningful branches over an exhaustive catalog. + +### Area exploration + +Answer: + +1. What outcome does this direction own? +2. Who uses or depends on it? +3. Which major capabilities and flows belong to it? +4. Which rules and boundaries shape it? +5. What is its current delivery state? +6. Which questions or decisions remain open? + +### Focused explanation + +Lead with current behavior. Then explain rules, exceptions, delivery, examples, +and only the evolution necessary to understand the present. + +## Conversation behavior + +- A broad question receives a useful answer before any clarifying question. +- Suggested follow-ups use names and descriptions the reader has just seen. +- Each follow-up narrows one level unless the reader asks to compare multiple + directions. +- Do not expose document paths, metadata, retrieval queries, or agent workflow + in the normal answer. +- Do not offer an engineering deep dive as the only next step. Keep product + exploration useful to nontechnical readers. +- Do not create knowledge while answering. A discovered gap becomes an + explicit optional next action. +- Do not search proposals or active changes during ordinary current-product + discovery. A roadmap question is a separate route and must remain labeled as + future work. + +## Failure conditions + +Fail the exploration when it: + +- asks the reader to name an Area or capability before showing what exists; +- starts with code, architecture, repositories, or file navigation; +- presents a flat inventory with no product hierarchy; +- hides delivery or uncertainty; +- treats planned or accepted-but-absent behavior as available; +- overwhelms the first answer with every rule and historical decision; +- modifies project state without a separate request; +- invents a complete project map from sparse or unverified knowledge. + +## Evaluation discipline + +Test discovery with natural prompts that do not reveal the expected taxonomy or +rubric. Keep assertions hidden from the tested agent. Test focused conformance +separately only after the exploration itself has surfaced a real Area or +capability name. diff --git a/.agents/skills/implement-work-item/SKILL.md b/.agents/skills/implement-work-item/SKILL.md new file mode 100644 index 0000000..01b7a93 --- /dev/null +++ b/.agents/skills/implement-work-item/SKILL.md @@ -0,0 +1,104 @@ +--- +name: implement-work-item +description: Claim and implement exactly one ready issue from a central project change bundle in its exact bound leaf checkout or worktree. Use when the maintainer explicitly asks to implement a named or next frontier issue, or when resuming its existing claim. Do not use for an unresolved Wayfinder issue, unapproved shaping, lightweight unrelated edits, or work in an inferred checkout. +--- + +# Implement Work Item + +Build one bounded unit from a fresh, explicit context. The issue tracks local +progress; `change.md` remains the parent contract. + +Read [the execution contract](references/execution-contract.md) before the +first claim in a repository. + +## Load before claiming + +1. Run `wfctl work issue show ` from the exact intended + leaf. If no issue was named, run `wfctl work issue list ` and + choose only a frontier issue. +2. Read every listed required file completely. This includes the parent spec, + selected issue, transitive blockers, and referenced artifacts. +3. After each complete read, run `wfctl work review file `. + Do not record a receipt after reading only headings, summaries, or excerpts. +4. Run `wfctl work status ` and verify the current Git root equals + the reported code root exactly. +5. Claim before analysis or edits: + +```sh +wfctl work issue claim --actor "agent:" +``` + +The claim records repository, branch, revision, and worktree identity. Stop on +any mismatch instead of choosing a sibling checkout. + +## Implement one tracer bullet + +Invoke `analyze-with-graphify` in every repository this issue legitimately +touches, then inspect the actual source. Recheck relevant curated knowledge +with `align-project-knowledge`. Search may supplement the graph; it does not +replace it. + +Implement the smallest complete behavior that satisfies this issue. Prefer a +high public seam and work one behavior cycle at a time: + +1. add one externally meaningful failing check; +2. confirm it fails for the intended reason; +3. make the minimum production change that passes it; +4. run the focused check; +5. repeat, then run broader relevant checks. + +Tests must derive expected behavior from the approved contract or an +independent authority, never from the implementation they are meant to test. +Do not over-mock the behavior under review. + +After every material maintainer turn or meaningful investigation cycle, apply +the preservation test from `manage-project-work`: if losing newly learned +information could cause repeated material investigation, a different choice, +misunderstanding, or unsafe action in a fresh session, append a complete entry +to the issue's `Discovery ledger`. Record observation, evidence, implication, +scope, and disposition without forcing it into a predefined finding category. +Update evidence and current understanding next, then refresh the issue's single +structured checkpoint last: + +```sh +wfctl work checkpoint --issue \ + --actor "agent:" \ + --state "" \ + --last "" \ + --next "" +``` + +Use `--status blocked --blocker ""` when progress genuinely cannot +continue. Record deviations in the parent `change.md` when they affect approved +scope, acceptance, or decisions; refresh the parent checkpoint and reopen +framing review before continuing materially different work. + +The checkpoint may identify the latest discovery and its effect on the next +action, but the full information stays in the semantic record or a linked +artifact. Never hide a discovery only in checkpoint prose, command output, or +conversation memory. + +## Resolve honestly + +Inspect the real diff and production path. Record commands, direct source +evidence, limitations, placeholders, and unresolved risk. With normal +maintainer authorization, preserve code in the exact bound Git commit; `wfctl` +never commits automatically. + +Resolve only this issue: + +```sh +wfctl work issue complete \ + --summary "" \ + --evidence "" +``` + +If interrupted, refresh the claimed issue checkpoint before stopping. A fresh +session begins with `wfctl work context --stage resume`, reads every required +file and discovery entry completely, and resumes the existing exact claim; it +does not infer another issue, actor, checkout, or code root. If +deliberately giving the issue back, run `wfctl work issue release`; it resets +the issue checkpoint to ready. Completion makes the issue checkpoint terminal; +then refresh the parent checkpoint with the next frontier action. Do not mark a +partial outcome completed. Final change-wide review, knowledge promotion, and +archival belong to `verify-project-work` after every required issue is terminal. diff --git a/.agents/skills/implement-work-item/agents/openai.yaml b/.agents/skills/implement-work-item/agents/openai.yaml new file mode 100644 index 0000000..cd9c2c7 --- /dev/null +++ b/.agents/skills/implement-work-item/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Implement Work Item" + short_description: "Implement one issue with durable discoveries" + default_prompt: "Use $implement-work-item to claim or resume one frontier issue in the exact bound checkout, preserve consequential discoveries in the issue, verify it, and refresh its checkpoint last." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/implement-work-item/references/execution-contract.md b/.agents/skills/implement-work-item/references/execution-contract.md new file mode 100644 index 0000000..5eea8b5 --- /dev/null +++ b/.agents/skills/implement-work-item/references/execution-contract.md @@ -0,0 +1,29 @@ +# Work-item execution contract + +## Workspace invariants + +- The central bundle is the record workspace. +- Each `Code root` from `wfctl work status` is an implementation workspace. +- A linked worktree is a distinct root even when it shares Git objects. +- Never infer a checkout from branch name, repository name, sibling paths, or + the location of `change.md`. +- Re-run status after directory changes, compaction, branch changes, and before + verification. + +## Progress invariants + +- Work exactly one claimed issue. +- Keep the issue current after requirements, evidence, scope, or the next + action changes. +- Keep parent acceptance and decisions in `change.md`; link rather than copy. +- A fresh session resumes from `wfctl work context`, the full files it lists, + and the exact claim—not from chat memory. + +## Verification invariants + +- Inspect the production path, not only tests or generated graph output. +- Prefer behavior checks at stable public seams. +- Run focused checks during development and the broader relevant suite before + resolution. +- Separate verified facts, inference, and unverified limitations. +- Passing checks do not excuse an acceptance criterion that was never traced. diff --git a/.agents/skills/manage-project-work/SKILL.md b/.agents/skills/manage-project-work/SKILL.md new file mode 100644 index 0000000..962a7cb --- /dev/null +++ b/.agents/skills/manage-project-work/SKILL.md @@ -0,0 +1,158 @@ +--- +name: manage-project-work +description: "Classify and route project work through the central knowledge-backed workflow. Use when a requested feature, fix, refactor, migration, investigation, operational change, product decision, or architecture change may be significant; when resuming active work after interruption; or when a useful lightweight result has no active or curated owner yet. This is the default project-work router: it decides between lightweight work, a pending capture, a bounded change, and deliberate Wayfinder, then maintains the owning active checkpoint." +--- + +# Manage Project Work + +Choose the least expensive path that preserves important intent, progress, and +evidence. Operate `wfctl` yourself; the maintainer supplies product authority, +corrections, and review decisions rather than managing files or commands. + +## Route the request + +Treat work as significant when it may change observable behavior, domain +meaning, a contract, state, security, reliability, operations, architecture, +ownership, or cross-repository coordination. Size alone is not decisive. + +- **Lightweight:** clearly local and behavior-preserving. Work directly. Offer + a pending capture only when a non-obvious reusable result should survive and + no active change or curated concept already owns it. +- **Bounded significant change:** the outcome can be specified honestly now. + Start one central bundle and use `specify-project-change`. +- **Wayfinder:** a consequential destination is visible, but dependent product + or architecture choices make an honest spec impossible across one session. + Recommend `shape-project-direction`; enter only after maintainer agreement. + +If classification is genuinely ambiguous, explain the material risk, recommend +one route, and ask for the maintainer's choice. Do not force full ceremony onto +trivial work or hide a significant change as lightweight. + +## Start one canonical bundle + +Create the bundle before extended significant-task discussion so the reasoning +survives compaction: + +```sh +wfctl work start --title "" --mode full|slice|wayfinder +``` + +Start from one leaf for single-repository delivery. Start from knowledge with +no leaf for project-only work, or repeat `--leaf` for known multi-repository +scope. Do not bind guessed leaves merely because Wayfinder may need them later. + +Run `wfctl work status <id>` and `wfctl work context <id> --stage shape`. +Use the returned bundle in knowledge for records and only the returned code +roots for implementation. Never create a competing spec or issue tracker in a +leaf. + +## Persist material change and discovery + +A turn is material whether it comes from the maintainer or the agent. It is +material when it changes a requirement, constraint, idea, alternative, +decision, rejection, deferral, scope boundary, evidence, risk, question, next +action, or the agent's understanding of the work. + +Before continuing, apply this preservation test: + +> If this newly learned information disappeared, could a fresh session repeat +> material investigation, choose differently, misunderstand the work, or act +> unsafely? + +If yes, append a complete entry to the owning record's `Discovery ledger`. +Record the observation, evidence or missing evidence, implication, applicable +scope or lifetime, and current disposition. Do not constrain discoveries to a +fixed taxonomy. Use the claimed issue during execution, `change.md` during +shaping/direct work/final review, or a linked artifact when the supporting +material is too large; the owning ledger must link that artifact. If the +discovery changes parent scope, acceptance, or decisions, update `change.md` +as well. Preserve invalidated discoveries with a corrected disposition rather +than erasing them. + +Then update the rest of the semantic record and checkpoint: + +1. append a concise proposed, approved, rejected, deferred, or superseded + decision-ledger entry when a choice changed; +2. update affected current state, scope, acceptance, issues, decisions, + progress, evidence, and discovery implications; +3. preserve rationale without copying the chat transcript or turning the + discovery ledger into an activity log; +4. run `wfctl work checkpoint <id>` for bundle-level work, or add + `--issue <issue-id>` for a claimed issue. Supply current state, last + completed action, exact next action, blockers, and actor. + +The checkpoint may mention a discovery ID and its effect on the frontier, but +must not duplicate the discovery. If useful material has no active or curated +owner, follow the pending-capture route instead. + +New bundle schemas require the ledger section. When an entry exists, workflow +context validates its stable `DISC-*` ID and non-empty observation, evidence, +implication, scope, and disposition. Fix malformed entries before continuing; +do not satisfy the gate with placeholders. + +The checkpoint hash binds the record after those edits. Never edit its YAML by +hand. If any owned record changes afterward, `wfctl work context` reports the +checkpoint stale and the agent must refresh it before claiming or closing work. + +After compaction, interruption, or a clean-session start, first run `wfctl work +context --stage resume` without an ID. It auto-selects only when exactly one +active record is bound to the current checkout. If none exists, do not invent +one. If several exist, run `wfctl work status`, present their human outcomes, +and ask the maintainer which one to resume; never guess from recency, branch, or +directory name. + +For the selected record, inspect status and the reported checkpoints, then +read every required file completely, including the entire discovery ledger, +before acting. Continue only in the exact reported code roots and existing +claim. If a binding or checkpoint is invalid, stop and reconcile it rather +than reconstructing state from chat memory. A checkpoint locates the frontier; +it never replaces the required full reads. + +If an upgraded legacy bundle has no structured checkpoint, read its current +record and former Progress/Handoff sections completely, then run `wfctl work +checkpoint` once to adopt the new model. Preserve the old prose as lineage, but +do not maintain a second resume state afterward. + +If a pre-ledger bundle has no `Discovery ledger`, do not fabricate past +discoveries. Add the section when material work next changes that owner and +preserve new discoveries from that point forward; old bundle versions remain +readable for compatibility. + +## Route the active bundle + +- Use `shape-project-direction` only for deliberate Wayfinder. +- Use `specify-project-change` to synthesize bounded work or collapse a clear + map into stable acceptance criteria. +- Use `split-project-change` when approved work needs several dependency-aware + sessions. +- Use `implement-work-item` for exactly one frontier issue. A small bounded + change may be implemented directly from `change.md` after framing approval, + while preserving the same workspace and progress rules. +- Use `verify-project-work` for complete file accounting, spec/implementation + reconciliation, knowledge promotion, completion review, and archival. + +Framing and completion decisions are recorded with `wfctl work approve <id> +--stage framing|completion --by human:<maintainer-id>`, never by editing +`maintainer_review` directly. The command requires an interactive terminal or +an out-of-band `--token`; a hand-written receipt fails the completion gate. + +`changes/active/<id>/` is already the knowledge-side living record. There is +no final dump into `raw/`. Completed closure moves the entire bundle intact to +`changes/archive/<id>/`; verified durable truth is separately curated into +`knowledge/`. + +## Pending capture + +Do not create a capture when an active change, issue, intake case, +reconstruction, or curated concept already owns the material; update that owner +and its checkpoint instead. When useful lightweight material genuinely has no +owner and the maintainer accepts retaining it, run: + +```sh +wfctl work capture add <slug> --title "<fact to retain>" +``` + +Complete the returned pending capture. It remains non-authoritative until the +knowledge agent routes it to a real destination or discards it through +`wfctl work capture resolve`. Never copy active progress into `changes/inbox/` +and never cite raw or intake material as current truth. diff --git a/.agents/skills/manage-project-work/agents/openai.yaml b/.agents/skills/manage-project-work/agents/openai.yaml new file mode 100644 index 0000000..b83903f --- /dev/null +++ b/.agents/skills/manage-project-work/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Manage Project Work" + short_description: "Route work and preserve resumable context" + default_prompt: "Use $manage-project-work to classify or resume this request, preserve consequential discoveries in their semantic owner, and refresh the owning checkpoint last." diff --git a/.agents/skills/manage-project-work/assets/bundle-review.md b/.agents/skills/manage-project-work/assets/bundle-review.md new file mode 100644 index 0000000..bd0545e --- /dev/null +++ b/.agents/skills/manage-project-work/assets/bundle-review.md @@ -0,0 +1,11 @@ +--- +workflow_version: 1 +kind: bundle-review +updated_at: "{{UPDATED_AT}}" +files: [] +--- + +# Bundle review + +This ledger is maintained by `wfctl`. Each receipt accounts for one complete +bundle file at an exact content hash. It is not proof of semantic correctness. diff --git a/.agents/skills/manage-project-work/assets/capture.md b/.agents/skills/manage-project-work/assets/capture.md new file mode 100644 index 0000000..560022d --- /dev/null +++ b/.agents/skills/manage-project-work/assets/capture.md @@ -0,0 +1,33 @@ +--- +capture_version: 1 +kind: capture +id: "{{CAPTURE_ID}}" +title: "{{TITLE}}" +status: pending +created_at: "{{CREATED_AT}}" +source: {} +claim_refs: [] +resolution: null +--- + +# Summary + +Record the reusable result, proposal, limitation, or observation. Separate +verified facts, maintainer intent, and inference. + +# Evidence + +Record exact source paths, revisions, commands, outputs, and known limits. For +intake or reconstruction material, retain fully qualified claim references in +`claim_refs`; never cite raw files as authority. + +# Why retained + +Explain why this material may affect a future product or engineering decision +despite not belonging to active work or curated knowledge yet. + +# Suggested route + +Recommend one next route: discard it, curate verified truth, or start/link a +normal significant change. The capture remains non-authoritative while it is +pending in `changes/inbox/`. diff --git a/.agents/skills/manage-project-work/assets/wayfinder-map.md b/.agents/skills/manage-project-work/assets/wayfinder-map.md new file mode 100644 index 0000000..61f9844 --- /dev/null +++ b/.agents/skills/manage-project-work/assets/wayfinder-map.md @@ -0,0 +1,36 @@ +--- +workflow_version: 1 +kind: wayfinder-map +status: charting +destination: "" +notes: [] +resolved: [] +fog: [] +out_of_scope: [] +created_at: "{{CREATED_AT}}" +updated_at: "{{CREATED_AT}}" +--- + +# Destination + +Name what reaching the end of this map makes possible. This is a planning +destination, not an instruction to implement it during Wayfinder. + +# Standing notes + +Record domain, constraints, accepted vocabulary, and skills or sources every +session must consult. + +# Resolved route + +`wfctl` maintains a one-line named pointer for each completed Wayfinder issue. +The issue remains the only home of its full resolution. + +# Not yet specified + +Keep in-scope fog that cannot yet be phrased as a precise question. Remove a +fog item when it becomes an issue or is ruled out of scope. + +# Out of scope + +Record boundaries outside this destination and the reason for each boundary. diff --git a/.agents/skills/manage-project-work/assets/work-issue.md b/.agents/skills/manage-project-work/assets/work-issue.md new file mode 100644 index 0000000..df4086f --- /dev/null +++ b/.agents/skills/manage-project-work/assets/work-issue.md @@ -0,0 +1,70 @@ +--- +workflow_version: 3 +kind: work-issue +id: "{{ISSUE_ID}}" +title: "{{TITLE}}" +phase: delivery +type: delivery +status: draft +blocked_by: [] +satisfies: [] +repositories: [] +artifacts: [] +claim: null +resolution: null +created_at: "{{CREATED_AT}}" +updated_at: "{{CREATED_AT}}" +checkpoint_version: 1 +checkpoint: + status: ready + stage: implement + actor: system:wfctl + current_state: Issue is ready but unclaimed. + last_completed: Issue record created. + next_action: Read the required context and claim the issue. + blockers: [] + updated_at: "{{CREATED_AT}}" + basis_sha256: "{{CHECKPOINT_BASIS}}" +--- + +# Outcome + +State the complete behavior, decision, or fact this issue must deliver. Keep it +small enough for one fresh agent session when possible. + +# Acceptance contribution + +Explain how this issue contributes to its `satisfies` acceptance IDs. For a +Wayfinder issue, state the precise question it resolves instead. + +# Constraints and boundaries + +Record relevant project knowledge, approved decisions, repository scope, and +explicit exclusions. Do not copy the parent specification. + +# Discovery ledger + +Preserve newly learned information when losing it could make a future session +repeat material investigation, choose differently, misunderstand the work, or +act unsafely. Do not use this as a chronological activity log or restrict it to +a predefined class of findings. + +Append one durable block per discovery, replacing the placeholders: + +```markdown +## DISC-NNN — Concise title + +- **Observation:** What was learned and its uncertainty. +- **Evidence:** Direct basis or missing evidence. +- **Implication:** What this changes. +- **Scope:** Where and for how long it applies. +- **Disposition:** Its current owner or next destination in plain language. +``` + +Preserve an invalidated entry and update its disposition rather than deleting +it. + +# Verification + +Record direct source inspection, executable checks, outcomes, and limitations. +Graph or search output is navigation evidence, not implementation proof. diff --git a/.agents/skills/manage-project-work/assets/work-spec.md b/.agents/skills/manage-project-work/assets/work-spec.md new file mode 100644 index 0000000..7cb17e3 --- /dev/null +++ b/.agents/skills/manage-project-work/assets/work-spec.md @@ -0,0 +1,177 @@ +--- +workflow_version: 5 +id: "{{WORK_ID}}" +title: "{{TITLE}}" +mode: "{{MODE}}" +status: shaping +scope: leaf +created_at: "{{CREATED_AT}}" +updated_at: "{{CREATED_AT}}" +checkpoint_version: 1 +checkpoint: + status: active + stage: shape + actor: system:wfctl + current_state: Initial framing is pending. + last_completed: Central work bundle created. + next_action: Persist the first agreed framing and refresh this checkpoint. + blockers: [] + updated_at: "{{CREATED_AT}}" + basis_sha256: "{{CHECKPOINT_BASIS}}" +repositories: [] +acceptance: [] +direction: + status: bounded + map: "" + resolved_at: "" +knowledge_alignment: + reviewed: [] + conflicts: [] +graph_evidence: + queries: [] +knowledge_promotion: + status: pending + concepts: [] + reason: "" +maintainer_review: + framing: + status: pending + by: "" + at: "" + notes: [] + completion: + status: pending + by: "" + at: "" + notes: [] +verification: + result: pending + revision: "" + worktree_id: "" + repositories: [] + acceptance: [] + acceptance_reviewed: false + implementation_reviewed: false + knowledge_reviewed: false + checks: [] + unresolved: [] +--- + +# Summary + +State the intended outcome and why it matters. + +# Current state + +Maintain the latest agreed problem, desired outcome, constraints, scope, +assumptions, and risks. Rewrite this section whenever the current understanding +changes; keep resumable execution state only in the structured checkpoint. + +# Direction map + +For a broad initiative, record the destination, current landscape, affected +Areas and actors, constraints, success signals, and explicit non-goals. For a +bounded task, state `Not required — bounded change` with a short reason. + +# Domain language + +Record proposed canonical terms, definitions, accepted aliases, names to +avoid, and their resolution state. Keep unresolved vocabulary here; promote it +to current knowledge only after approval. + +# Decision frontier + +List only unresolved choices whose answers can materially change product +meaning, scope, architecture, ownership, or the next safe action. Rank them by +leverage. For a bounded task with no frontier, state `None`. + +# Uncertainty and fog + +Record unknown facts, missing authority, contradictions, dependencies, and +risks. For each item, state its impact and what would resolve it. + +# Open questions + +- Record unresolved questions that can change the solution, authority, or scope. + +# Discussion and decision ledger + +Append one concise entry after every material maintainer turn. Preserve rejected, +deferred, and superseded directions instead of rewriting history. + +| At | Status | Subject | Outcome and rationale | +| --- | --- | --- | --- | +| {{CREATED_AT}} | proposed | Initial framing | Awaiting discussion. | + +# Current behavior and evidence + +Record Graphify queries, the source locations they led to, and direct inspection +of the actual code. Graph output is navigation evidence, never the authority for +an implementation claim. Add supplementary text-search findings only after +graph analysis. + +# Discovery ledger + +Preserve newly learned information when losing it could make a future session +repeat material investigation, choose differently, misunderstand the work, or +act unsafely. This is an information-acquisition ledger, not an activity log and +not a closed list of "findings" categories. + +Append one durable block per discovery, replacing the placeholders: + +```markdown +## DISC-NNN — Concise title + +- **Observation:** What was learned, including uncertainty when unverified. +- **Evidence:** Direct basis, or the evidence that is still missing. +- **Implication:** What this changes for understanding or action. +- **Scope:** Where it applies and any lifetime or invalidation condition. +- **Disposition:** What now owns it or must happen next, in plain language. +``` + +Preserve superseded or disproven entries and update their disposition instead +of deleting the path by which the team learned. + +# Knowledge alignment + +List relevant vision, product, architecture, decision, repository, and uncertainty concepts. Record conflicts and maintainer resolutions. + +# Scope + +## In + +- Define included behavior. + +## Out + +- Define explicit exclusions. + +# Decisions + +- Record the current approved decisions with enough rationale to guide + implementation. Link each entry to its ledger history when it evolved. + +# Plan and progress + +- [ ] Add concrete implementation and validation steps. + +# Acceptance criteria + +- [ ] Add observable, testable completion criteria. + +# Verification evidence + +Record fresh commands, results, directly inspected code at the bound revision, +and criterion-by-criterion inspection. A passing test suite or Graphify result +alone is not complete evidence. + +# Knowledge promotion + +List the curated concepts updated by this change, or explain why the completed +change does not alter durable project intent, meaning, decisions, contracts, +boundaries, ownership, or operational knowledge. Promote only claims verified +against authoritative sources; untrusted raw intake is never provenance. + +# Deviations and unresolved work + +State deviations, remaining risks, placeholders, mocks, follow-ups, or `None`. diff --git a/.agents/skills/qmd/SKILL.md b/.agents/skills/qmd/SKILL.md new file mode 100644 index 0000000..0d4b048 --- /dev/null +++ b/.agents/skills/qmd/SKILL.md @@ -0,0 +1,295 @@ +--- +name: qmd +description: Search local markdown knowledge bases, notes, docs, and wikis with QMD. Use when users ask to find notes, retrieve documents, inspect a wiki, answer from indexed markdown, or set up QMD access. +license: MIT +compatibility: Requires qmd CLI or MCP server. Install via `npm install -g @tobilu/qmd`. +metadata: + author: tobi + version: "2.2.0" +allowed-tools: Bash(qmd:*), mcp__qmd__* +--- + +# QMD - Query Markdown Documents + +## How search works + +QMD searches local markdown collections: notes, docs, wikis, transcripts, and +project knowledge bases. Use it before web search when the answer may already be +in indexed local files. + +The workflow is always: + +1. Search for candidate documents. +2. Retrieve the full source with `qmd get` or `qmd multi-get`. +3. Answer from retrieved text, citing paths or docids. + +Do not answer from snippets alone when the user needs facts, decisions, quotes, +or nuance. Snippets are only leads. + +Typical loop: + +```bash +qmd search "merchant reality support interviews" -n 5 +# leads: #abc123 concepts/customer-proximity.md; #def432 sources/merchant-call.md +qmd multi-get "#abc123,#def432" --format md +``` + +**Default to structured `qmd query` with `intent:`, `lex:`, `vec:`, and `hyde:` +fields that you write yourself.** You are a better query expander than the +built-in model: you know the user's actual goal, the domain vocabulary, and the +nearby-but-wrong concepts to avoid. Do not just paste the user's words into +`qmd query "..."` and hope the expansion model guesses right — supply the +`intent:` and craft the lexical and semantic terms deliberately (see +[Pick the right search mode](#pick-the-right-search-mode)). + +When reporting what you retrieved, a compact note is enough; do not paste whole +files unless needed: + +```text +Retrieved: +- #abc123 concepts/customer-proximity.md +- #def432 sources/merchant-call.md +``` + +## Pick the right search mode + +Use **BM25 lexical search** when you know exact words, titles, names, code +symbols, or rare phrases: + +```bash +qmd search "cockpit OKR Goodhart" -n 10 +qmd search '"AI Before Headcount"' -c concepts -n 5 +``` + +Use **`qmd query` with structured fields** when the user describes an idea +indirectly, uses different wording than the source, or needs conceptual recall. +**This is the default mode — write the fields yourself rather than leaning on +query expansion.** Combine exact anchors with semantic recall: + +```bash +qmd query $'intent: Find the concept note about metrics as instruments without letting OKRs replace judgment.\nlex: cockpit instruments OKR Goodhart metrics judgment\nvec: data informed not metric driven product judgment\nhyde: A concept note says metrics are useful like cockpit instruments, but leaders should remain data-informed rather than metric-driven because OKRs and dashboards can Goodhart product judgment.' +``` + +Structured query fields (you author each one — do not delegate this to the +expansion model): + +- `intent:` states what you are trying to find **and what to avoid**. Always + supply this. It steers ranking away from nearby-but-wrong concepts. +- `lex:` exact terms, aliases, titles, code symbols, and rare words you expect + in the source. This is your own keyword expansion. +- `vec:` paraphrases the idea in natural language, in source-like wording. +- `hyde:` describes the document or answer that would satisfy the request. + +You do not need all four every time, but you should almost always write at least +`intent:` plus one of `lex:`/`vec:`. A bare `qmd query "the user's sentence"` +throws away the context only you have and relies on the built-in expander to +reconstruct it — prefer the structured form. + +If you genuinely have nothing to expand (a single rare token, a verbatim phrase), +that is a job for `qmd search`, not bare `qmd query`: + +```bash +qmd query --format json --explain $'intent: ...\nlex: ...\nvec: ...' # inspect ranking +``` + +If `qmd query` is slow or model/GPU setup fails, fall back to `qmd search` with +better lexical terms. + +## Retrieve sources + +Search results include docids like `#abc123` and `qmd://...` paths. Fetch them: + +```bash +qmd get "#abc123" +qmd get qmd://concepts/ai-before-headcount.md +qmd multi-get "#abc123,#def432" --format md +qmd multi-get 'concepts/{ai-before-headcount.md,data-informed-not-metric-driven.md}' --format md +qmd multi-get 'sources/podcast-2025-*.md' -l 80 +``` + +Use `multi-get` when comparing several hits or gathering context across pages. + +### Output is line-numbered and carries the docid — cite both + +`get` and `multi-get` are **line-numbered by default** and always print the +document's `#docid` and `qmd://` path. So `get` output looks like: + +```text +qmd://concepts/note.md #abc123 +--- + +1: # Metrics as instruments +2: +3: Treat dashboards like cockpit instruments... +``` + +Cite the docid and exact line numbers in your answer, and use the numbers to ask +for the next slice. Pass `--no-line-numbers` only when you need raw content to +copy verbatim (e.g. reproducing a code block). + +When you need to open or edit the underlying file (e.g. hand a path to `Read`, +`Edit`, or an editor), add `--full-path`. It replaces the `qmd://` URL + docid +header with the document's on-disk path, falling back to the canonical header if +the file no longer exists on disk: + +```text +$ qmd get "#abc123" --full-path +/Users/you/notes/concepts/note.md +--- + +1: # Metrics as instruments +``` + +`--full-path` works the same way on `qmd search` and `qmd query`: result paths +become the file's on-disk path — `./`-prefixed relative path when the file is +inside `$PWD`, absolute realpath otherwise — and the per-result `#docid` is +dropped because the path is the identifier. The leading `./` is intentional so +the output is unambiguously a filesystem path and cannot be mistaken for a bare +collection-relative string. Default search/query output still uses `qmd://` +URIs; only opt into `--full-path` when you specifically need a path you can hand +to a non-QMD tool. + +### Read line ranges with the `:from:count` suffix — never pipe through `sed`/`head`/`tail` + +`qmd get` slices files itself. Use the suffix or flags; do **not** shell out to +`sed -n`, `head`, `tail`, or `awk` to pull a line range. Piping defeats docid +resolution, virtual-path lookups, line numbering, and the header, and it is +slower and more error-prone. + +The most compact form is a `:from:count` suffix right on the path or docid — +prefer it: + +```bash +qmd get "#abc123:120:40" # 40 lines starting at line 120 +qmd get qmd://concepts/note.md:200:60 # lines 200–259 +qmd get "#abc123:120" # from line 120 to end of file +qmd get "#abc123" --from 120 -l 40 # equivalent, using flags +``` + +Suffix and flags: + +- `<path>:<from>:<count>` — start at line `<from>`, read `<count>` lines. **Best + for reading around a search hit.** +- `<path>:<from>` — start at `<from>`, read to end of file. +- `--from <line>` / `-l <lines>` — flag equivalents. Explicit flags override the + suffix, so `... :5:2 -l 1` reads 1 line. +- `--no-line-numbers` — drop the `N:` prefixes (line numbers are on by default). + +Wrong: `qmd get "#abc123" | sed -n '120,160p'` +Right: `qmd get "#abc123:120:40"` + +Search results include a `:line` anchor on each hit — feed it straight into +`qmd get path:line:<n>` to read a window around the match (line numbers in the +output will start at `line`). + +## Discover what is indexed + +```bash +qmd collection list +qmd ls +qmd status +``` + +Add collection filters when broad searches drift into the wrong corpus: + +```bash +qmd search "headcount autonomous agents" -c concepts -n 10 +qmd query "merchant support product reality" -c concepts -c sources -n 10 +``` + +Omit `-c` to search everything. + +## MCP Tool: `query` + +When using the MCP server, prefer structured searches: + +```json +{ + "searches": [ + { "type": "lex", "query": "cockpit OKR Goodhart" }, + { "type": "vec", "query": "data informed not metric driven product judgment" }, + { "type": "hyde", "query": "A concept note explains that metrics are useful as instruments, but leaders should not let OKRs or dashboards replace judgment." } + ], + "intent": "Find the concept note about using metrics as instruments without becoming metric-driven.", + "collections": ["concepts"], + "limit": 10 +} +``` + +Query types: + +- `lex` — BM25 keyword search. Best for exact terms, names, titles, and code. +- `vec` — vector semantic search. Best for natural-language concepts. +- `hyde` — vector search using a hypothetical answer/document passage. + +## Query craft + +Good QMD searches mix three things: + +1. **Title/alias anchors:** exact page titles, named entities, phrases. +2. **Semantic paraphrase:** how a human would describe the idea. +3. **Negative space:** enough intent to avoid nearby-but-wrong concepts. + +Examples: + +```bash +# Exact-ish title lookup +qmd search '"arm the rebels" merchants tools big companies' -c concepts + +# Semantic concept lookup +qmd query $'intent: Find the customer proximity concept, not generic customer delight.\nlex: support pseudonymous merchant customer interviews\nvec: founder stays close to merchant reality through support and product use' + +# Source lookup +qmd search "six-week cadence WhatsApp merchant relationships Shawn Ryan" -c sources -n 10 +``` + +## Setup and maintenance + +Only mutate indexes when the user asked for setup or maintenance. Searching and +retrieving are safe; collection/index mutation is not a casual first step. + +```bash +npm install -g @tobilu/qmd +qmd collection add ~/notes --name notes +qmd update +qmd embed +``` + +Health and diagnostics: + +```bash +qmd doctor +qmd status +qmd pull +``` + +`qmd doctor` checks config, model cache, device/GPU setup, vector fingerprints, +and common environment overrides. If a model-backed command fails, run it before +changing configuration. + +## MCP setup + +See `references/mcp-setup.md` for Claude Code, Claude Desktop, OpenClaw, and HTTP +server configuration. + +## Pitfalls + +- **Do not stop at snippets.** Fetch documents before making claims. +- **Do not slice files with `sed`/`head`/`tail`.** Use the `path:from:count` + suffix (e.g. `qmd get "#abc123:120:40"`) or `--from`/`-l`. Output is already + line-numbered; piping breaks docid resolution, the header, and virtual paths. +- **Do not lean on query expansion.** Write `intent:`/`lex:`/`vec:`/`hyde:` + yourself. A bare `qmd query "user sentence"` discards the context only you + have. You expand the query; the model just ranks. +- **Do not overuse semantic search.** If you know exact titles or terms, BM25 is + faster and often better. +- **Do not mutate indexes casually.** `qmd collection add`, `qmd update`, and + `qmd embed` change local state and can be expensive. +- **Model-backed commands can be environment-sensitive.** If `qmd query`, + `qmd vsearch`, or reranking fails because local models/GPU are unavailable, + use `qmd search` and stronger lexical/structured terms. +- **Ambiguous user wording needs intent.** Add `intent:` rather than hoping query + expansion guesses the right domain. +- **Collection names matter.** Search `concepts` for synthesized wiki pages, + `sources` for transcripts/raw source pages, and docs collections for code or + project documentation. diff --git a/.agents/skills/qmd/references/mcp-setup.md b/.agents/skills/qmd/references/mcp-setup.md new file mode 100644 index 0000000..5d32a62 --- /dev/null +++ b/.agents/skills/qmd/references/mcp-setup.md @@ -0,0 +1,102 @@ +# QMD MCP Server Setup + +## Install + +```bash +npm install -g @tobilu/qmd +qmd collection add ~/path/to/markdown --name myknowledge +qmd embed +``` + +## Configure MCP Client + +**Claude Code** (`~/.claude/settings.json`): +```json +{ + "mcpServers": { + "qmd": { "command": "qmd", "args": ["mcp"] } + } +} +``` + +**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`): +```json +{ + "mcpServers": { + "qmd": { "command": "qmd", "args": ["mcp"] } + } +} +``` + +**OpenClaw** (`~/.openclaw/openclaw.json`): +```json +{ + "mcp": { + "servers": { + "qmd": { "command": "qmd", "args": ["mcp"] } + } + } +} +``` + +## HTTP Mode + +```bash +qmd mcp --http # Port 8181 +qmd mcp --http --daemon # Background +qmd mcp stop # Stop daemon +``` + +## Tools + +### structured_search + +Search with pre-expanded queries. + +```json +{ + "searches": [ + { "type": "lex", "query": "keyword phrases" }, + { "type": "vec", "query": "natural language question" }, + { "type": "hyde", "query": "hypothetical answer passage..." } + ], + "limit": 10, + "collection": "optional", + "minScore": 0.0 +} +``` + +| Type | Method | Input | +|------|--------|-------| +| `lex` | BM25 | Keywords (2-5 terms) | +| `vec` | Vector | Question | +| `hyde` | Vector | Answer passage (50-100 words) | + +### get + +Retrieve document by path or `#docid`. + +| Param | Type | Description | +|-------|------|-------------| +| `path` | string | File path or `#docid` | +| `full` | bool? | Return full content | +| `lineNumbers` | bool? | Add line numbers | + +### multi_get + +Retrieve multiple documents. + +| Param | Type | Description | +|-------|------|-------------| +| `pattern` | string | Glob or comma-separated list | +| `maxBytes` | number? | Skip large files (default 10KB) | + +### status + +Index health and collections. No params. + +## Troubleshooting + +- **Not starting**: `which qmd`, `qmd mcp` manually +- **No results**: `qmd collection list`, `qmd embed` +- **Slow first search**: Normal, models loading (~3GB) diff --git a/.agents/skills/setup-workflow-environment/SKILL.md b/.agents/skills/setup-workflow-environment/SKILL.md new file mode 100644 index 0000000..fc6d370 --- /dev/null +++ b/.agents/skills/setup-workflow-environment/SKILL.md @@ -0,0 +1,132 @@ +--- +name: setup-workflow-environment +description: Install, update, repair, or explain the shared project workflow environment in a clean or initialized knowledge repository or leaf source repository. Use when a maintainer asks to bootstrap wfctl, connect a repository to project knowledge, update workflow rules or skills, preserve existing AGENTS.md or CLAUDE.md instructions, or diagnose a broken workflow installation. +--- + +# Setup Workflow Environment + +Install through deterministic `wfctl` operations while preserving all unowned consumer content. + +## Command ownership + +Run `wfctl init`, `wfctl upgrade`, and `wfctl check` yourself when terminal +access permits. Ask the maintainer for repository kind, paths, skill targets, +scope, and conflict decisions, not routine command execution. Provide a manual +command only when bootstrapping without `wfctl`, missing authority, or tool +access prevents execution; state that blocker explicitly. + +Treat `wfctl init knowledge`, `wfctl init leaf`, and repository-local `wfctl +upgrade` as the normal optional maintainer-facing CLI entry points, and all may +be delegated to this skill. `--target`, `check`, `knowledge`, `work`, QMD, and +Graphify operations belong to the agent unless the maintainer explicitly +requests manual or automation-oriented instructions. + +## Procedure + +1. Confirm that Bun and `wfctl` are available. If `wfctl` is missing, stop and + tell the maintainer how to install or link the canonical Bun package. +2. Confirm `qmd --version` reports at least `2.5.3`. If it is missing or old, + ask for installation authority and run + `bun install -g @tobilu/qmd@2.5.3`. QMD is the supported knowledge + retrieval engine; do not substitute a custom indexer. +3. Before leaf initialization, require both the `graphify` CLI and the official + native Graphify skill in the current session. Do not require either for a + knowledge repository that is not inspecting source. If the CLI is absent, + ask for user-level installation authority and run `uv tool install + graphifyy`. If the native skill is absent, ask for authority and run + `graphify install --platform <agent>` once for every selected agent + platform. Then tell the maintainer to restart the agent and stop: an on-disk + skill is not active in the current session. After restart, verify both + requirements and repeat `wfctl init leaf`. Knowledge retrieval and raw + intake use QMD, not Graphify. +4. Identify whether the target is a `knowledge` or `leaf` repository. + A knowledge target may be a new directory without Git: ask the maintainer + for authority to initialize it and pass `--init-git`. In an interactive + terminal, `wfctl` asks this itself. A leaf must already be an existing Git + repository; never use `--init-git` to turn an arbitrary leaf path into one. +5. For a leaf repository, obtain the local knowledge-repository path. Do not guess it. +6. Inspect existing `AGENTS.md`, `CLAUDE.md`, `.claude/rules`, `.agents/skills`, and `.claude/skills`, including symlink targets. +7. Run `wfctl init <knowledge|leaf> --target <path>` with `--knowledge <path>` + for a leaf. Use the default project skill scope unless the maintainer chooses + user scope or no skill installation. The dependency preflight must pass + before the command writes files. A successful leaf initialization must also + run `graphify update .` from the exact target checkout; do not accept a + checkout whose local graph was never built. Preserve the existing root + `.gitignore` while ensuring it excludes `graphify-out/`. Successful leaf + init must also register durable repository identity in knowledge and add + this exact worktree to ignored local state. It must not change that + repository's active reconstruction selection. +8. Review the preview before confirming. For each conflict, preserve the + existing content, accept the offered per-file backup and replacement, or + stop. Never invent a blanket overwrite. +9. Let `wfctl` update managed instruction and guide blocks. If their markers + are malformed or duplicated, stop and repair them with the maintainer rather + than replacing the whole file. Use `wfctl init <kind> + --print-instructions agents|guide` to obtain the exact managed text. +10. Confirm that `PROJECT_WORKFLOW.md` preserves pre-existing text outside the + managed block. +11. Confirm the installed skills include the version-matched official `qmd` + skill from `qmd skills path qmd` for every selected agent. New skills are + not active in the current session automatically; tell the maintainer to + restart the agent session before knowledge-dependent work. + Confirm `explore-project-knowledge` is installed for both profiles so + project discovery works identically from knowledge and leaf sessions. + Confirm `shape-project-direction`, `specify-project-change`, + `split-project-change`, and `implement-work-item` are installed for both + profiles. For a knowledge profile, also confirm + `research-project-context` is installed. +12. For a knowledge profile, confirm that `.qmd/index.yml` defines separate + `knowledge`, `changes`, `intake`, `reconstruction`, and `raw` collections. + Confirm that `reconstruction/active` and `reconstruction/archive` exist and + `reconstruct-project-knowledge` is installed. `wfctl init` + builds `.workflow/current/knowledge-graph.json` plus + `.workflow/current/claim-ledger.json` and runs `qmd update`, so explicit + knowledge navigation, claim-lineage audit, and BM25 retrieval must be + ready immediately. Treat + `qmd-models` and `qmd-embeddings` warnings as optional semantic setup, not + as lexical-index success. Ask before `qmd pull` or `qmd embed`; the current + model set is roughly 2 GB. +13. Run `wfctl check --target <path>` and report every failure and warning. For + a leaf, confirm `graphify-graph` passes and refers to this checkout, not a + sibling repository or another worktree, and confirm `graphify-ignore` + passes. Confirm `repository-connection` identifies this exact known + checkout and reports whether it is selected as the default, awaiting + selection, or registered as an alternative for reconstruction. + For a knowledge repository, report registered repositories, known + worktrees, and explicit default reconstruction selections. Deferred + selection is healthy during setup and must not be reported as a warning. + Do not select during + initialization; `reconstruct-project-knowledge` owns contextual selection + when reconstruction is actually requested. +14. For a knowledge profile, run `wfctl knowledge validate` and + `wfctl knowledge build`; do not create a raw intake case unless intake + processing was requested. +15. Point the maintainer to `PROJECT_WORKFLOW.md`; it defines their review + responsibilities for both profiles. + +Use `wfctl upgrade` for an existing installation. A workflow 0.3 knowledge +repository requires a content migration after the generated assets upgrade: + +1. inventory every existing curated concept without changing its meaning; +2. route stakeholder current behavior to `curate-product-knowledge`; +3. route technical realization to `curate-engineering-knowledge`; +4. split any mixed concept and connect the two views with explicit links; +5. preserve decision lineage, provenance, delivery state, and uncertainty; +6. invoke `verify-knowledge-quality` and record fresh quality plus normal + verification receipts for the final content hash; +7. run `wfctl knowledge validate` and `wfctl knowledge build`. + +Never infer a view, audience, or product meaning merely to silence an upgrade +error. Leave the concept draft and ask the maintainer when authority is +missing. Read [the installation contract](references/install-contract.md) when +handling unusual files or symlinks. + +## Safety + +- Preserve text outside `wfctl` markers. +- Never replace an existing file or directory with a symlink. +- Treat local edits to owned generated files as conflicts. +- Do not claim Codex consumes `.claude/rules`; Codex receives the routing contract through `AGENTS.md`. +- Let the pinned `skills` CLI install both workflow skills and QMD's + version-matched native skill for the selected agent targets and scope. Do not + hand-copy skills around it. diff --git a/.agents/skills/setup-workflow-environment/agents/openai.yaml b/.agents/skills/setup-workflow-environment/agents/openai.yaml new file mode 100644 index 0000000..e922f25 --- /dev/null +++ b/.agents/skills/setup-workflow-environment/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Setup Workflow Environment" + short_description: "Install or repair wfctl environments" + default_prompt: "Use $setup-workflow-environment to install or repair this repository workflow safely." diff --git a/.agents/skills/setup-workflow-environment/references/install-contract.md b/.agents/skills/setup-workflow-environment/references/install-contract.md new file mode 100644 index 0000000..9a087bb --- /dev/null +++ b/.agents/skills/setup-workflow-environment/references/install-contract.md @@ -0,0 +1,140 @@ +# Installation contract + +## Instruction files + +- Create `AGENTS.md` when absent. +- Update only the block between `<!-- wfctl:begin -->` and `<!-- wfctl:end -->`. +- When `CLAUDE.md` is absent, link it to `AGENTS.md`. +- When `CLAUDE.md` is a regular file, preserve it and maintain a separate marked block. +- Treat malformed, duplicated, broken, or externally targeted managed symlinks as conflicts. + +## Skills + +- Delegate skill placement to the pinned `skills` CLI. +- Use the installer's copy mode for every selected agent. Do not create + cross-agent symlinks between `.agents/skills` and `.claude/skills`. +- Default to project scope; use user scope or no installation only when the + maintainer chooses it. +- Install `setup-workflow-environment`, `analyze-with-graphify`, and the + version-matched official `qmd` skill for both profiles. +- Install `operate-project-knowledge` only for the knowledge profile as the + default router for explanation, history, audit, navigation, contradiction, + and triage requests. +- Install `explore-project-knowledge` for both profiles. It gives knowledge and + leaf sessions the same read-only progressive product-discovery path through + the configured knowledge repository without requiring user knowledge of + Areas, capabilities, or files. +- Install `process-raw-intake` only for the knowledge profile. +- Install `reconstruct-project-knowledge` only for the knowledge profile. +- Install `research-project-context` only for the knowledge profile. +- Install `shape-project-direction` for both profiles so an unbounded + significant initiative can resolve its Wayfinder map inside the same central + bundle before implementation starts. +- Install `specify-project-change`, `split-project-change`, and + `implement-work-item` for both profiles so specification synthesis, + dependency-aware issue creation, and exact-worktree execution are explicit + reusable modes rather than hidden conventions. +- Install `curate-project-knowledge` for both profiles because a leaf agent + must promote durable truth before closing significant work. +- Install `curate-product-knowledge`, `curate-engineering-knowledge`, and + `verify-knowledge-quality` for both profiles. The orchestrator depends on + them to keep stakeholder and technical views separate and to bind independent + authority/truth and reader-communication review to the current content hash. +- Install alignment, work management, and verification skills for both + profiles. Knowledge needs them for project-only and multi-repository bundles; + a project-only bundle has no implementation checkout. +- Select Codex, Claude, or both explicitly. +- Let the pinned installer update an already owned selected skill in + non-interactive mode. Remove only obsolete project-scope workflow skills + whose lock entry still identifies this package; never remove an unowned + skill by name. + +The workflow skill `analyze-with-graphify` is a routing and policy gate, not a +copy of Graphify's native skill. Require the `graphify` CLI and the official +native `graphify` skill supplied by that tool whenever source code must be +analyzed. Verify native-skill availability against the current session +catalog; an on-disk file alone does not prove the running agent loaded it. +When the CLI is absent, ask for user-level installation authority and run +`uv tool install graphifyy`. Install the native skill with `graphify install +--platform <agent>` for every selected platform, then restart the session. +`graphify install --help` is the authority for currently supported platform +names. Do not report setup success until the restarted session exposes the +native skill. +Do not require Graphify for Markdown intake or OKF curation that does not inspect +source code. + +Run `graphify update .` from the exact leaf checkout after applying an +initialization or upgrade. This graph is worktree-local evidence infrastructure: +do not reuse a sibling checkout's `graphify-out`, and do not report setup +success when the update command fails or the resulting graph has no nodes. +Maintain a `# wfctl:begin` / `# wfctl:end` block in the root `.gitignore` when +no existing exact `graphify-out/` rule already covers the artifact. Preserve +every pre-existing ignore rule. + +The QMD skill is not maintained as a workflow copy. Resolve its source with +`qmd skills path qmd`, then let the same pinned `skills` CLI copy it to the +selected Codex and Claude targets. This preserves agent-target and scope +semantics while keeping the skill matched to the installed QMD version. + +## Knowledge retrieval + +- Require QMD `>=2.5.3` for both profiles because leaf alignment reads the + linked knowledge repository and skill discovery depends on + `qmd skills path qmd`. +- Install the supported baseline through Bun when authorized: + `bun install -g @tobilu/qmd@2.5.3`. +- For a knowledge profile, let `wfctl` own `.qmd/index.yml` and + `.qmd/.gitignore`. +- Keep QMD's database and model cache out of Git. The index is disposable and + rebuildable from repository content. +- Include only `knowledge` in unscoped searches. Require explicit collection + selection for `changes`, `intake`, `reconstruction`, and `raw`. +- Run QMD from the knowledge root so it uses the project-local index. +- Run `qmd update` during knowledge initialization and upgrade so lexical BM25 + retrieval is ready before success is reported. +- Diagnose `qmd status` and `qmd doctor` separately. A working lexical index is + required. Missing models or stale embeddings are warnings until semantic or + hybrid retrieval is needed. + +## Knowledge and claim relationship graphs + +- Compile authored Markdown links and workflow relation metadata with + `wfctl knowledge build`; do not infer semantic relationships. +- Store the generated knowledge artifact at + `.workflow/current/knowledge-graph.json` and the explicit intake/ + reconstruction claim ledger at `.workflow/current/claim-ledger.json`; both + are already ignored. +- Build both during a valid knowledge initialization or upgrade. +- Make `wfctl check` fail when either artifact is missing, invalid, or stale. +- Keep QMD responsible for retrieval and Graphify responsible for source-code + structure. The compiled graphs only represent authored knowledge links and + explicit claim lineage. + +## Rules + +- Install readable copies under `.workflow/rules`. +- Mirror namespaced rule files under `.claude/rules`. +- Route Codex to `.workflow/rules` through the managed `AGENTS.md` block. + +## Maintainer guide + +- Install `PROJECT_WORKFLOW.md` as a managed, visible root document for both + profiles, using a managed block that preserves text outside the markers. +- Render profile-specific content and the configured knowledge path. +- Treat malformed or duplicated managed markers as a conflict. +- Stop for maintainer-controlled repair when markers cannot be updated safely. + Obtain the exact block with `wfctl init <kind> --print-instructions guide`. + +## Ownership + +`.workflow/state.json` records the hash of every installed owned file. Update an owned file only when the on-disk hash still matches the prior installed hash. Equal content is safe to adopt. Any other pre-existing content is a conflict. +When a later release removes an owned file, delete it only if its hash still +matches the recorded installed hash. Back it up first. A locally modified +obsolete file remains an explicit conflict. + +`.workflow/repositories.json` is a dynamic, Git-tracked project source +registry, not a generated asset. It stores repository identity without local +paths. `.workflow/current/repositories.json` is ignored and stores any number +of known local worktrees per repository plus one explicit active selection +used only by default reconstruction. Leaf initialization adds a known worktree +but never changes an existing selection. diff --git a/.agents/skills/shape-project-direction/SKILL.md b/.agents/skills/shape-project-direction/SKILL.md new file mode 100644 index 0000000..318f24e --- /dev/null +++ b/.agents/skills/shape-project-direction/SKILL.md @@ -0,0 +1,71 @@ +--- +name: shape-project-direction +description: Run deliberate Wayfinder for a consequential project initiative whose destination is visible but route, product meaning, or architecture remains too foggy for one honest specification or agent session. Use only when the maintainer explicitly requests Wayfinder or accepts the router's recommendation. Do not use for ordinary brainstorming, a bounded large feature, current-project explanation, or product-code implementation. +--- + +# Shape Project Direction + +Wayfinder finds a route; it does not build the destination. It stores one +low-resolution map and bounded question issues inside the same central bundle +that later becomes the delivery specification. + +Read [the Wayfinder contract](references/direction-shaping-contract.md) before +charting or resuming. + +## Chart the map + +1. Reuse the initiative's active bundle or start one with `wfctl work start + <slug> --title "<destination>" --mode wayfinder`. Bind only already-known + evidence leaves; unknown future implementation repositories are not guessed. +2. Run `wfctl work context <id> --stage wayfind` and `wfctl work status <id>`. + Do not edit product source during this phase. +3. Name the destination first: what a finished map will make specifiable, for + whom, and the boundary it must not cross. +4. Explore breadth-first. Put a precise answerable question into a Wayfinder + issue. Put only still-unphraseable in-scope uncertainty into `map.md` fog. + Put ruled-out work under out of scope. +5. Create currently visible blockers before their dependants with `wfctl work + issue create --phase wayfinding --type + research|prototype|grilling|task`. Use `wfctl work issue block|unblock` when + later evidence changes an edge. Stop charting; do not resolve a normal issue + in the same session. + +If breadth-first exploration reveals no meaningful fog and the whole route fits +one session, stop and recommend ordinary `specify-project-change` instead. + +## Work one frontier question + +Run `wfctl work map status <id>`. Without a named issue, choose the first +frontier item. Read the map, selected issue, parent change, blockers, and +referenced artifacts completely; record current hash receipts. Claim before +work: + +```sh +wfctl work issue claim <id> <issue-id> --actor "agent:<identity>" +``` + +- `research` discovers an external or project fact without supplying product + authority; +- `prototype` creates a cheap artifact for a real human to react to; +- `grilling` resolves one product/domain decision with the maintainer, one + evidence-backed question at a time; +- `task` performs a prerequisite that makes a later decision possible. + +Ask one focused question at a time. Persist the answer before selecting another. + +Never answer the human side of a human-in-the-loop issue yourself. Complete at +most one non-research issue per session. Record the full answer and evidence in +the issue, then run `wfctl work issue complete`. The CLI adds only a named gist +to the map. Update newly visible issues and remove the corresponding fog so one +fact never lives in two competing places. + +## Hand off to specification + +The route is clear only when every Wayfinder issue is completed or explicitly +dropped, no in-scope fog remains, and the next bounded change can be specified +without guessing. Invoke `specify-project-change`: it must read all resolved +issues, synthesize stable acceptance criteria into `change.md`, obtain review, +and run `wfctl work map finish`. + +Do not jump from a map directly into implementation. The retained `map.md` is +decision lineage, while `change.md` becomes the current delivery contract. diff --git a/.agents/skills/shape-project-direction/agents/openai.yaml b/.agents/skills/shape-project-direction/agents/openai.yaml new file mode 100644 index 0000000..26653bd --- /dev/null +++ b/.agents/skills/shape-project-direction/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Shape Project Direction" + short_description: "Resolve a large uncertain project direction" + default_prompt: "Use this skill to turn a broad, uncertain product or architecture initiative into a durable decision frontier before implementation." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/shape-project-direction/references/direction-shaping-contract.md b/.agents/skills/shape-project-direction/references/direction-shaping-contract.md new file mode 100644 index 0000000..1117873 --- /dev/null +++ b/.agents/skills/shape-project-direction/references/direction-shaping-contract.md @@ -0,0 +1,59 @@ +# Wayfinder contract + +## Entry test + +Use Wayfinder only when all are true: + +- the initiative can materially change product meaning, architecture, + ownership, or several Areas; +- the destination can be named, but multiple dependent choices still hide the + route; +- one honest specification cannot yet fit in a single well-reasoned session; +- the maintainer explicitly selected or accepted this mode. + +A large but bounded feature, migration, audit, or refactor belongs to ordinary +significant work. Brainstorming remains conversational until durable shaping is +actually wanted. + +## Map roles + +`map.md` is an index, not the store of every answer: + +- `destination` fixes what the route is finding and therefore its scope; +- `notes` holds standing domain and process context; +- `resolved` points by issue name to full issue resolutions; +- `fog` holds in-scope uncertainty that cannot yet be phrased precisely; +- `out_of_scope` holds work beyond the destination that never graduates. + +A precise unanswered question is an issue, even when blocked. A vague suspected +question remains fog. When resolution makes fog precise, create the new issue +and remove that fog item. + +## Issue roles + +- **Research (agent-driven):** establishes a fact from primary or project + sources. It cannot establish product authority. +- **Prototype (human-in-the-loop):** creates a cheap disposable artifact to + make a design question concrete. +- **Grilling (human-in-the-loop):** resolves a product, domain, or architecture + choice one focused question at a time. +- **Task (agent- or human-driven):** performs a prerequisite that exposes facts + needed by later decisions; it does not deliver the destination. + +The frontier contains ready, unclaimed issues whose blockers are completed. +Claim before work. Resolve one non-research issue per session. The full answer +and evidence live in that issue; the map stores only a gist and pointer. + +## Exit test + +Wayfinder is ready for specification only when: + +- destination and out-of-scope boundaries are explicit; +- every issue is completed or deliberately dropped with a reason; +- no in-scope fog remains; +- accepted language and decisions are distinguishable from proposals; +- the next bounded change can be specified without guessing. + +`specify-project-change` then reads the complete map bundle, synthesizes +`change.md`, and finishes the map into `full` or `slice`. It preserves `map.md` +as lineage and never bypasses specification into implementation. diff --git a/.agents/skills/specify-project-change/SKILL.md b/.agents/skills/specify-project-change/SKILL.md new file mode 100644 index 0000000..bee7312 --- /dev/null +++ b/.agents/skills/specify-project-change/SKILL.md @@ -0,0 +1,85 @@ +--- +name: specify-project-change +description: Synthesize an already-discussed bounded change, or a resolved Wayfinder map, into the one central project specification. Use when the maintainer explicitly asks to write or refresh the spec, when a direction map is ready to become delivery work, or before splitting approved work into issues. Do not use to discover a huge unresolved direction, implement code, or create a second specification outside the active knowledge bundle. +--- + +# Specify Project Change + +Turn the context already earned through discussion, curated knowledge, and +source inspection into one buildable contract. Do not restart an interview or +discard decisions merely because a fresh template would be easier. + +Read [the specification contract](references/specification-contract.md) before +editing the bundle. + +## Bind and read + +1. Resolve the active change ID. If no significant-work bundle exists, invoke + `manage-project-work` to classify and start it first. +2. Run `wfctl work context <id> --stage shape`. For a resolved Wayfinder map, + run `--stage review` so every map issue and artifact is enumerated. +3. Read every required file completely, including content below long tables or + headings. Run `wfctl work review file <id> <path>` only after the whole file + has been read and reconciled. +4. Run `wfctl work status <id>`. Treat `Spec` as the only editable + specification and every `Code root` as an exact evidence workspace, never + as a path inferred from repository name. + +## Synthesize, do not invent + +Use the current discussion without asking the maintainer to repeat it. Reconcile +it with relevant curated knowledge and verified source reality. If code informs +the contract, invoke `analyze-with-graphify` in every relevant bound root, then +open the actual source locations. Invoke `align-project-knowledge` before +settling product or architecture meaning. + +Update `change.md` with: + +- the problem and intended observable outcome; +- actors, current behavior, constraints, and explicit exclusions; +- approved product and engineering decisions with rationale; +- stable acceptance entries `AC-01`, `AC-02`, ... in frontmatter; +- the highest practical test seams and what behavior each seam proves; +- unresolved authority or facts, without guessing them away; +- the current ledger and structured resumable checkpoint. + +Acceptance criteria describe observable outcomes and boundary behavior, not a +file-by-file implementation plan. Preserve an ID when wording improves without +changing meaning. Retire or supersede changed meaning explicitly. + +Ask at most one blocking question at a time. Include verified facts, the +decision it unlocks, viable choices, and a recommendation. Persist the answer +before continuing. After each material edit or maintainer answer, refresh the +bundle checkpoint with `wfctl work checkpoint <id>`; run it last so its hash +binds the current `change.md` rather than an earlier draft. + +## Review and continue + +Present a compact framing packet: outcome, scope, exclusions, decisions, +acceptance IDs, test seams, risks, and unresolved work. Record only explicit +maintainer approval, and record it through the approval command rather than by +editing the receipt: + +```sh +wfctl work approve <id> --stage framing \ + --by human:<maintainer-id> \ + --note "<what was approved>" +``` + +The command requires an interactive terminal, or `--token` matching +`WFCTL_APPROVAL_TOKEN` in unattended use. A hand-written +`maintainer_review.framing` receipt fails verification. Approving rewrites +`change.md`, so re-read it, refresh its review receipt, and refresh the +checkpoint afterwards. + +For Wayfinder, read every resolved issue in full, collapse its linked detail +into the specification, clear all legitimate fog, review every current bundle +file, then run: + +```sh +wfctl work map finish <id> --mode full|slice +``` + +The map remains as history; it is not copied into a parallel strategy file. +After approval, invoke `split-project-change` when the work needs multiple +fresh sessions. A small bounded change may remain entirely in `change.md`. diff --git a/.agents/skills/specify-project-change/agents/openai.yaml b/.agents/skills/specify-project-change/agents/openai.yaml new file mode 100644 index 0000000..8a5543a --- /dev/null +++ b/.agents/skills/specify-project-change/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Specify Project Change" + short_description: "Synthesize one central change specification" + default_prompt: "Use $specify-project-change to synthesize the current discussion, project knowledge, and verified source context into the active central specification and refresh its checkpoint." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/specify-project-change/references/specification-contract.md b/.agents/skills/specify-project-change/references/specification-contract.md new file mode 100644 index 0000000..0bbdb26 --- /dev/null +++ b/.agents/skills/specify-project-change/references/specification-contract.md @@ -0,0 +1,29 @@ +# Specification contract + +The specification states what must become true and why. It is not a transcript, +an issue list, or an implementation diary. + +## Required synthesis + +- Explain the problem from the affected actor's perspective. +- Explain the outcome from that actor's perspective. +- Preserve the project's accepted domain language. +- Separate product behavior from engineering decisions without losing the link. +- Prefer existing high-level test seams; propose a new seam only when current + interfaces cannot prove the behavior safely. +- Record out-of-scope boundaries and known risks explicitly. +- Make every acceptance criterion observable, independently referencable, and + falsifiable. + +Do not include volatile source paths or large code snippets as the contract. +Exact source evidence belongs in progress and verification. A concise schema, +state machine, or type shape from a prototype may be retained when prose would +lose a settled decision; identify it as prototype-derived. + +## Wayfinder collapse + +The map is an index. Each completed issue owns the detailed answer. Synthesis +therefore requires reading all resolved issues, not expanding the one-line map +gists into guesses. Keep the map as lineage, carry accepted conclusions into +`change.md`, keep deferred questions visible, and do not create delivery issues +until this collapse is reviewed. diff --git a/.agents/skills/split-project-change/SKILL.md b/.agents/skills/split-project-change/SKILL.md new file mode 100644 index 0000000..b8413ae --- /dev/null +++ b/.agents/skills/split-project-change/SKILL.md @@ -0,0 +1,67 @@ +--- +name: split-project-change +description: Split one approved central change specification into dependency-aware tracer-bullet issues inside the same knowledge bundle. Use when the maintainer explicitly asks to create the execution breakdown, when delivery spans several safe agent sessions, or when parallel work needs an explicit frontier. Do not use before framing approval, for an unresolved Wayfinder map, or to create an external or leaf-local competing tracker. +--- + +# Split Project Change + +Create bounded execution units without fragmenting the specification or losing +acceptance coverage. + +Read [the issue-design contract](references/issue-design-contract.md) before +proposing the graph. + +## Read the approved contract + +1. Run `wfctl work context <id> --stage shape` and `wfctl work status <id>`. +2. Read `change.md` completely and confirm that framing is approved, the mode + is `full` or `slice`, and stable acceptance IDs exist. +3. Reuse current source and knowledge understanding. If the proposed breakdown + depends on implementation facts not yet checked, invoke Graphify-first + analysis in the exact relevant code roots before publishing tickets. + +## Draft the graph + +Prefer narrow complete tracer bullets: one issue produces independently +reviewable behavior across every necessary layer and fits in one fresh session. +Do not split work into database/API/UI horizontal layers merely because the +repository does. + +For each proposed issue show the maintainer: + +- a human-readable title; +- the complete behavior or decision it delivers; +- stable acceptance IDs it contributes to; +- exact repository identities it may touch; +- genuine blocking issues; +- whether it is small enough for one fresh context. + +Use expand-migrate-contract for a wide mechanical refactor that cannot keep the +system valid as independent vertical slices. Keep each migration batch bounded +by blast radius and make final contraction depend on every migration. + +Ask whether the granularity and dependency edges are right. Publish only the +approved graph. + +## Publish centrally + +Create issues in dependency order so blocker IDs already exist: + +```sh +wfctl work issue create <change-id> <slug> \ + --title "<title>" \ + --phase delivery \ + --type delivery \ + --satisfies AC-01 \ + --repository <repository-id> \ + --blocked-by ISSUE-001 +``` + +Repeat options as needed. Each issue receives its own ready checkpoint; never +create a second issue or progress file in a leaf. Run `wfctl work issue list +<change-id>` and confirm every acceptance ID is covered, the graph is acyclic, +and the frontier matches the intended concurrency. Refresh the parent +checkpoint last with the first executable frontier action. + +Do not implement during this skill. Hand a frontier issue to +`implement-work-item` in a fresh context. diff --git a/.agents/skills/split-project-change/agents/openai.yaml b/.agents/skills/split-project-change/agents/openai.yaml new file mode 100644 index 0000000..c6cb2a7 --- /dev/null +++ b/.agents/skills/split-project-change/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Split Project Change" + short_description: "Split a change into checkpointed issues" + default_prompt: "Use $split-project-change to split the approved central specification into dependency-aware issues with one ready checkpoint each." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/split-project-change/references/issue-design-contract.md b/.agents/skills/split-project-change/references/issue-design-contract.md new file mode 100644 index 0000000..2cb0c00 --- /dev/null +++ b/.agents/skills/split-project-change/references/issue-design-contract.md @@ -0,0 +1,27 @@ +# Issue design contract + +An issue is an executable unit, not a copy of the parent spec. + +## Good issue + +- Delivers one complete behavior or resolves one explicit work prerequisite. +- Fits in one fresh agent session when possible. +- Names the acceptance IDs it contributes to. +- Declares only dependencies that genuinely prevent starting. +- Identifies repository scope without prescribing stale file paths. +- Can be verified independently at a public or project-approved seam. +- Leaves one precise, hash-bound checkpoint after every material turn. + +## Bad splits + +- one ticket per technical layer; +- one giant issue that silently relies on conversation memory; +- blockers used merely as ordering preferences; +- acceptance text duplicated and allowed to drift; +- source paths or snippets treated as permanent requirements; +- a local leaf checklist competing with the central bundle. + +Prefactoring may be its own prerequisite issue when it makes the later behavior +safe and easy. It must still define an observable structural outcome and fresh +checks. Wide refactors use expand-migrate-contract rather than pretending each +layer is independently shippable. diff --git a/.agents/skills/verify-knowledge-quality/SKILL.md b/.agents/skills/verify-knowledge-quality/SKILL.md new file mode 100644 index 0000000..2d7e779 --- /dev/null +++ b/.agents/skills/verify-knowledge-quality/SKILL.md @@ -0,0 +1,95 @@ +--- +name: verify-knowledge-quality +description: Perform the mandatory two-axis semantic gate for curated workflow knowledge before a document becomes stable or is reported complete. Use after creating or materially editing any product, engineering, decision, reference, or uncertainty concept, or for an explicit quality audit. Review authority and truth independently from reader communication, then bind both passes to one unchanged content hash. Do not use for ordinary explanation and do not let deterministic validation, polished prose, or a single self-review satisfy both axes. +--- + +# Verify Knowledge Quality + +Act as an adversarial review coordinator, not the author defending the draft. +Truth and communication are different failure surfaces; neither may compensate +for the other. + +Read [the quality rubric](references/quality-rubric.md) before the first review +in a session. + +## Freeze the review target + +1. Read the complete target document, not a snippet. +2. Read its parent Area index, every linked current product or engineering + counterpart, every current decision that governs it, and every material + authoritative source. +3. For code-backed claims, invoke `analyze-with-graphify` in the exact pinned + leaf and directly inspect the cited source, tests, and necessary runtime + evidence. +4. Run `wfctl knowledge validate --concept <path>` and distinguish structural + failures from semantic failures. Structural success is necessary but never + sufficient. +5. Run `wfctl knowledge hash --concept <path>` and pin the candidate content + hash before semantic review. + +## Run two independent axes + +1. Apply [the authority and truth review](references/authority-review.md). +2. Separately apply + [the reader communication review](references/reader-communication-review.md) + for the declared view and audience. +3. Use independent reviewer contexts when the runtime safely provides them. + Otherwise perform two explicitly separated passes from their own checklists + and evidence packets. Do not reuse the first pass's verdict as evidence for + the second. +4. Search across both passes for omitted exceptions, unsupported present + tense, mixed audiences, hidden implementation detail, intent inferred from + code, history presented as current, and claims broader than evidence. +5. Return one review packet with: + - result: passed or failed; + - separate authority-truth and reader-communication results; + - each failed, uncertain, unread, or blocked check; + - exact evidence and conflicting evidence; + - the smallest correction; + - authority needed from the maintainer, if any. +6. Recompute the content hash. If it changed, discard both passes and rerun + them on the new revision. +7. Do not write a passed receipt while any item is failed, uncertain, unread, + or blocked. + +## Record a passed receipt + +After all substantive content is final: + +1. Run `wfctl knowledge hash --concept <path>`. +2. Set: + +```yaml +x-wf: + quality: + status: passed + by: "<producer>/<version>" + at: "<ISO-8601>" + content_hash: "<wfctl knowledge hash output>" + checks: + - factuality + - audience-fit + - abstraction + - completeness + - delivery-state + axes: + authority-truth: + status: passed + by: "<producer>/<version>" + at: "<ISO-8601>" + content_hash: "<same wfctl knowledge hash output>" + reader-communication: + status: passed + by: "<producer>/<version>" + at: "<ISO-8601>" + content_hash: "<same wfctl knowledge hash output>" +``` + +3. Use the same hash in the normal `verified` event after the applicable + machine or human authority review. +4. Re-run `wfctl knowledge validate --concept <path>`. A material edit changes + the hash and invalidates both receipts. + +The receipt proves that the declared review was performed against one exact +document revision. It does not create authority and does not make an incorrect +review true. diff --git a/.agents/skills/verify-knowledge-quality/agents/openai.yaml b/.agents/skills/verify-knowledge-quality/agents/openai.yaml new file mode 100644 index 0000000..7d58a0f --- /dev/null +++ b/.agents/skills/verify-knowledge-quality/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Verify Knowledge Quality" + short_description: "Review truth and communication independently" + default_prompt: "Use $verify-knowledge-quality to run separate authority-truth and reader-communication reviews against one unchanged knowledge content hash." diff --git a/.agents/skills/verify-knowledge-quality/references/authority-review.md b/.agents/skills/verify-knowledge-quality/references/authority-review.md new file mode 100644 index 0000000..47ce721 --- /dev/null +++ b/.agents/skills/verify-knowledge-quality/references/authority-review.md @@ -0,0 +1,35 @@ +# Authority and truth review + +Review whether the document is entitled to make each claim. Ignore elegance, +tone, and readability except where ambiguity changes meaning. + +## Evidence packet + +Read: + +- the complete target; +- every governing current decision; +- every cited authoritative source in full enough to test the claim; +- current counterpart views and contradictory evidence; +- pinned source, tests, and runtime receipts for implementation claims. + +Retrieval snippets, raw, intake prose, compiled graphs, and agent summaries are +navigation only. + +## Checks + +- Split material statements into atomic claims. +- Match each claim to the correct authority class. +- Confirm source identity, revision, scope, and freshness. +- Ensure accepted intent, observed delivery, and alignment remain independent. +- Reject claims broader than their evidence. +- Find missing conditions, exceptions, failure modes, non-goals, and + contradictions. +- Verify that history is historical and current truth is current. +- Verify decision lineage and explicit maintainer authority for normative + meaning. +- Treat negative or absence claims as unproven without complete applicable + coverage. + +Return `passed`, `failed`, `uncertain`, or `blocked` per check. A missing or +unread source cannot pass. diff --git a/.agents/skills/verify-knowledge-quality/references/quality-rubric.md b/.agents/skills/verify-knowledge-quality/references/quality-rubric.md new file mode 100644 index 0000000..d63ef5f --- /dev/null +++ b/.agents/skills/verify-knowledge-quality/references/quality-rubric.md @@ -0,0 +1,102 @@ +# Knowledge quality rubric + +Every item is `passed`, `failed`, `uncertain`, or `blocked`. Only an all-passed +review may create a quality receipt. + +Run the common truth checks as the `authority-truth` axis and the view-specific +checks as the `reader-communication` axis. Bind both results to the same +unchanged content hash. Deterministic validation is a separate structural +gate. + +## Authority-truth axis + +### Factuality + +- Every material current claim has matching authority. +- Sources were read directly and match the exact revision. +- Raw, intake, retrieval snippets, generated graphs, and agent prose are not + treated as authority. +- Conflicting evidence and uncertainty are visible. +- Claims do not exceed the scope of their evidence. + +### Delivery state + +- Accepted intent, observed delivery, and alignment are independent. +- Present tense does not imply unavailable behavior. +- `absent`, `partial`, `implemented`, `verified`, `retired`, and `unknown` + match the actual evidence. +- Planned and rejected behavior remains outside current knowledge. + +### Completeness + +- Important rules, outcomes, boundaries, exceptions, non-goals, failure modes, + and affected relationships were not dropped. +- The document is the smallest coherent unit, not a fragment that hides a + material condition. +- Current decisions and supersession lineage are linked. + +### Freshness and lineage + +- `generated.at`, sources, realization assessment, and linked decisions refer + to the same current state. +- Historical explanation is clearly historical. +- A changed decision updates the current view and preserves predecessor + lineage. + +## Reader-communication axis + +### Product-view checks + +### Audience fit + +- A product manager or client can understand the main answer without + engineering knowledge. +- Domain terms are explained. +- The document leads with product outcome and observable behavior. +- Examples are domain examples, not code or API examples. + +### Abstraction + +- No code, identifiers, paths, endpoints, schemas, protocols, storage + mechanisms, package names, or implementation walkthroughs appear. +- `Engineering details` is link-only. +- Replacing the implementation without changing behavior would not require + rewriting the product explanation. +- Simplification did not erase a rule or exception. + +### Engineering-view checks + +### Audience fit + +- An engineer or operator can locate ownership, implementation, flow, + contracts, failure behavior, and verification. +- Technical terms are precise and useful for maintenance. + +### Abstraction + +- Product meaning is linked, not reconstructed from code. +- The document explains implementation rather than duplicating product prose. +- Technical detail is proportional to maintenance and verification needs. +- Repository, revision, paths, tests, and runtime evidence are pinned where + material. + +### Decision-view checks + +- Context, exact decision, rationale, consequences, affected knowledge, and + lineage are present. +- Alternatives, transition, and unresolved questions are concise and honest + when material; the document does not invent ceremonial content. +- Product effect is understandable without implementation detail. +- Technical consequences link to engineering knowledge. +- Supersession is reciprocal, acyclic, and leaves one stable current record. + +## Evaluation discipline + +This rubric follows agent-evaluation practice: inspect both outcome and +process, combine deterministic checks with semantic and human review, and test +real failure cases rather than relying on self-assessment. + +- Anthropic agent evals: + https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents +- Anthropic skill creation and baseline comparison: + https://github.com/anthropics/skills/blob/main/skills/skill-creator/SKILL.md diff --git a/.agents/skills/verify-knowledge-quality/references/reader-communication-review.md b/.agents/skills/verify-knowledge-quality/references/reader-communication-review.md new file mode 100644 index 0000000..f26e892 --- /dev/null +++ b/.agents/skills/verify-knowledge-quality/references/reader-communication-review.md @@ -0,0 +1,40 @@ +# Reader communication review + +Review whether the intended reader can recover the correct meaning without +hidden context. Do not accept a document merely because its evidence is strong. + +## Product view + +Evaluate as a product manager, client, maintainer, or domain expert: + +- the purpose and current behavior are clear before history; +- canonical domain terms are defined and used consistently; +- actors, rules, outcomes, boundaries, exceptions, and delivery are visible; +- examples are recognizable domain scenarios; +- technical details do not leak into the explanation; +- planned, partial, absent, retired, and unknown behavior cannot be mistaken + for available behavior; +- links provide optional depth without being required to understand the main + answer. + +## Engineering view + +Evaluate as an engineer or operator: + +- product meaning is linked rather than re-invented; +- ownership, entrypoints, flow, contracts, state, failures, operations, and + verification can be located; +- terminology and boundaries are precise; +- implementation detail is proportional to maintenance needs; +- evidence and revision scope are clear. + +## Decision, reference, and uncertainty views + +- a decision explains the durable choice, rationale, consequences, and + lineage without ceremonial padding; +- a reference distinguishes external fact from project choice; +- an uncertainty states the live question, impact, known facts, and required + authority or evidence. + +Return `passed`, `failed`, `uncertain`, or `blocked` per check. Identify the +smallest wording or structural correction, but do not silently change facts. diff --git a/.agents/skills/verify-project-work/SKILL.md b/.agents/skills/verify-project-work/SKILL.md new file mode 100644 index 0000000..cb47054 --- /dev/null +++ b/.agents/skills/verify-project-work/SKILL.md @@ -0,0 +1,106 @@ +--- +name: verify-project-work +description: Verify a significant project change against every file in its central bundle, every stable acceptance ID, and every exact bound source revision without hiding gaps. Use before claiming a bounded change, slice, multi-repository delivery, project-only decision, or architecture change complete; before knowledge promotion; or when auditing an implementation against its approved contract. +--- + +# Verify Project Work + +Completion is a claim supported by complete accounting and fresh semantic +evidence. Structural green output is never proof by itself. + +Read [the completion gate](references/completion-gate.md) before recording final +receipts. + +## Account for the whole bundle + +1. Run `wfctl work status <id>` and stop on any checkout, branch, worktree, or + binding mismatch. +2. Run `wfctl work context <id> --stage review`. +3. Read every required file completely, including long issue tails and every + artifact that informs the result. Mark only a genuinely irrelevant + supporting artifact `irrelevant`, with a reason. +4. After each read, record `wfctl work review file <id> <path>`. Re-read and + refresh a receipt whenever the file changes. +5. Run `wfctl work review status <id>` and require zero unseen, + changed-after-review, or invalid files. + +## Review on independent axes + +**Contract axis:** map every stable acceptance ID to non-dropped issues, +production behavior, direct evidence, and a verification receipt. Find missing +requirements, partial behavior, scope creep, and implementation that appears +present but contradicts the contract. + +**Engineering axis:** inspect the real diff and production path in every exact +code root. Invoke Graphify-first analysis, then open actual source, callers, +boundaries, state, errors, and consumers. Check project standards, architecture, +security, operations, and maintainability independently of whether the spec was +followed. + +Run focused behavior checks and the broader relevant test, build, type, lint, +and runtime checks. Expected values must come from the contract or an +independent authority, not the implementation. Look adversarially for disabled +paths, placeholders, mocks, fixtures, temporary compatibility code, unhandled +branches, and silently deferred work. + +Record one exact repository/revision/worktree receipt per bound leaf. Require a +clean commit containing the reviewed implementation; obtain normal maintainer +authorization before committing. For project-only work, verify decisions, +knowledge, and links without inventing code evidence. + +## Promote and close + +Decide whether verified durable truth changed. If yes, route product behavior +and engineering realization through their separate curation skills, run the +two-axis knowledge quality gate, validate every changed concept, and list exact +concepts under `knowledge_promotion`. Otherwise record a concrete no-update +reason. + +A concept promoted from a bundle in a project with no reconstructed baseline +carries the same shape as one established by whole-project reading and a much +narrower footing: it was derived from whatever this task happened to touch. Say +so in its `maintainer-decision` source — name the bundle and state that no +reconstruction has covered this subject — so a later baseline knows to re-derive +it rather than treat it as already settled. Promote it anyway: knowledge grown +from real work is better than none, and the cost of the shortcut is only hidden +when nobody writes it down. + +Present acceptance results, engineering findings, checks, deviations, risks, +and knowledge delta as one completion review packet. Record the maintainer's +explicit decision through the approval command; never write the receipt by +hand: + +```sh +wfctl work approve <id> --stage completion \ + --by human:<maintainer-id> \ + --note "<what the maintainer accepted>" +``` + +It requires an interactive terminal, or `--token` matching +`WFCTL_APPROVAL_TOKEN`. `wfctl work verify` rejects a receipt with no matching +approval record. Finish all semantic edits to `change.md`, then refresh its +checkpoint in review stage **before** recording the final hash receipt: + +```sh +wfctl work checkpoint <id> \ + --actor "agent:<identity>" \ + --stage review \ + --state "Final verification and maintainer decision are recorded." \ + --last "Reconciled acceptance, implementation, and knowledge promotion." \ + --next "Re-read changed bundle files, refresh their receipts, and run the completion gate." +``` + +Re-read `change.md` completely after that command and refresh its receipt, plus +every other file changed by the review. Require a current checkpoint and zero +unseen, changed-after-review, or invalid files. Then run: + +```sh +wfctl work verify <id> +wfctl work close <id> --outcome completed|partial|abandoned +``` + +Use the honest outcome. Completed closure fails on open issues or claims, +unresolved Wayfinder state, acceptance gaps, stale file receipts, dirty or +mismatched source revisions, a stale checkpoint, missing evidence, or +incomplete promotion. Closing the bundle makes its checkpoint terminal; do not +create a capture for this completed session state. diff --git a/.agents/skills/verify-project-work/agents/openai.yaml b/.agents/skills/verify-project-work/agents/openai.yaml new file mode 100644 index 0000000..548e7dc --- /dev/null +++ b/.agents/skills/verify-project-work/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Verify Project Work" + short_description: "Verify every bound source against one spec" + default_prompt: "Use $verify-project-work to verify project-only knowledge work or every bound leaf checkout against the central spec and recorded evidence." diff --git a/.agents/skills/verify-project-work/references/completion-gate.md b/.agents/skills/verify-project-work/references/completion-gate.md new file mode 100644 index 0000000..a1cd1fe --- /dev/null +++ b/.agents/skills/verify-project-work/references/completion-gate.md @@ -0,0 +1,44 @@ +# Completion gate + +A completed record requires: + +- every plan and acceptance checkbox resolved; +- the change and every relevant issue have a current structured checkpoint; +- every stable acceptance ID marked verified and paired with passed evidence; +- every bundle file except the review ledger accounted for at its current hash; +- no unseen, changed-after-review, or invalid bundle file; +- every work issue completed or explicitly dropped, with no active claim; +- every acceptance ID covered by a non-dropped delivery issue when issues are used; +- no issue dependency cycle or completed issue with an unresolved blocker; +- any retained Wayfinder map resolved, with no remaining fog; +- at least one relevant Graphify query recorded for code-scoped work; +- `acceptance_reviewed: true`; +- `implementation_reviewed: true` for code-scoped work, or + `knowledge_reviewed: true` for project-only work; +- `maintainer_review.framing.status: approved` with a human actor and timestamp; +- `maintainer_review.completion.status: approved` with a human actor and timestamp; +- `knowledge_promotion.status: applied` with validated concept paths, or + `not-needed` with a concrete reason; +- one or more fresh checks with commands and outcomes; +- `verification.result: passed`; +- no unresolved item without an explicit accepted disposition; +- a deviations section that says `None` or names every remaining gap. +- every bound source checkout clean so each recorded commit actually contains + its verified implementation; +- a matching revision, worktree ID, and checks receipt for every bound + repository. Single-leaf work may use the top-level verification fields; + multi-repository work uses `verification.repositories`. + +Update semantic records first, refresh the owning checkpoint last, then re-read +the changed record and record its final file receipt. A checkpoint edit changes +the file hash; recording the receipt before the checkpoint would immediately +make that receipt stale. + +The CLI validates the bundle graph, file hashes, record structure, exact source +bindings, and any promoted concept files. It +cannot prove that a conversation occurred, that semantic evidence is correct, +or that no material claim was omitted. The agent must record a maintainer +approval only after an explicit decision, and the maintainer remains +responsible for that decision. + +If a requirement is intentionally dropped, update scope and record who accepted the change. Do not merely check it off. diff --git a/.claude/rules/evidence-first.md b/.claude/rules/evidence-first.md new file mode 100644 index 0000000..f4db33e --- /dev/null +++ b/.claude/rules/evidence-first.md @@ -0,0 +1,60 @@ +# Evidence-first reasoning + +Treat claims, plans, existing documents, and prior agent output as unverified until checked. + +For any task that depends on understanding, locating, changing, debugging, +reviewing, or verifying source code: + +1. Invoke `analyze-with-graphify` before reading or searching source code, even + when the maintainer does not mention Graphify. +2. Require it to inspect the current session skill catalog and invoke the + official native `graphify` skill. +3. Stop and tell the maintainer when either the Graphify CLI or native session + skill is unavailable. Offer the supported installation and session-restart path. +4. Use text search only after graph traversal, as a supplementary precision tool. +5. Inspect the actual source reached through the graph and record the query, + pinned revision, paths, symbols, and checks in the active change or curation + record. Graphify output is navigation, not authority. + +Do not use Graphify as the primary analyzer for raw or curated Markdown. +Use QMD for Markdown retrieval and the generated knowledge graph for explicit +relationship expansion. Use the generated claim ledger only for explicit +intake/reconstruction lineage. Neither graph is evidence; read selected files +directly. Before +knowledge-dependent work, require the official native `qmd` skill in the +current session and invoke it. An on-disk skill installed after session start +does not count; stop and request installation or a session restart instead of +inventing a partial QMD procedure. Raw coverage comes from Git-frozen intake +sources and explicit full-file review. Existing-project coverage comes from +exact clean source revisions, Graphify traversal, direct source and test +inspection, Git history review, repository dossiers, cross-repository +reconciliation, and maintainer adjudication. Curated trust comes from OKF +metadata, authoritative provenance, and current verification. QMD rank, +snippets, and its index are never authority. + +Assume no source is in good condition. Documents may reference deleted paths, +specifications may have been rewritten without supersession, notes meant to be +temporary may be the only written intent, and history may record file moves +rather than decisions. Judge each source as it actually is in this project, not +as its kind is supposed to be. + +When a source turns out to be unreliable, that narrows what it can establish and +widens nothing else. Do not answer it by electing a replacement authority: every +source stays a witness, and reconciling them is the work. Keep reading the +degraded source for terminology, chronology, and leads. Where it contradicts +itself over time, reconcile by chronology rather than choosing a version. + +When no source can establish something, say so and record it as unknown. An +honest unknown is a result; a plausible answer assembled from the least-bad +source available is a guess the maintainer can no longer see through. + +Never establish that work is progressing by checking that a process exists, and +never identify a process by matching its name: the pattern matches the shell +doing the checking, so a command that is waiting on itself reports as running. +Ask the data instead — the counters, records, or files the job writes. When a +background command is reported silent, that is a prompt to check, not a finding. +Compare consumed CPU time against elapsed time, read what the job has written, +and only then decide. Do not agree that something is broken because it was +reported, and do not restart healthy work; that costs more than waiting. + +Do not turn mocks, fixtures, fakes, placeholders, disabled checks, or partial wiring into production completion claims. Name missing evidence and unfinished work directly. diff --git a/.claude/rules/execution-continuity.md b/.claude/rules/execution-continuity.md new file mode 100644 index 0000000..c338666 --- /dev/null +++ b/.claude/rules/execution-continuity.md @@ -0,0 +1,37 @@ +# Execution continuity + +This applies while executing accepted work: a claimed issue, an approved scope, +a frozen frontier. It does not apply while shaping, specifying, or grilling a +decision with the maintainer. There the question count should be high, one +focused question at a time is correct, and stopping to ask is the work itself. + +Ending a turn is an act, not a default. The whole corpus says how to ask the +maintainer and nothing says when not to, so an unowned pause reads as safe when +it is only cheap. While accepted work remains, hold three lines. + +**Find it before asking it.** Locate the question in the accepted material — +the specification, the issue, the discovery ledger, the parent bundle's +decisions, the case record — and quote the line that answers it. A question the +material already answers is not a question. Absence of a quote is not permission +to ask; it is evidence the search was not done. + +**A report is not the finish line.** Completion is defined by the issue's +acceptance criteria and proven by its terminal status. A finished plan item, a +written summary, a passing subtask, and a refreshed checkpoint are progress. +Never announce the next action and then stop: either take it, or record why it +cannot be taken. + +**A discovery is not a stop.** Material that contradicts what was accepted is an +entry in the discovery ledger, and the work continues. Stop only when the +decision is irreversible beyond the current bundle, or when no unblocked work +remains — repeated identical failure, unavailable authority, a blocked source. +Discomfort and uncertainty are not on that list. Record the uncertainty as +uncertainty and keep going. + +Questions that genuinely need the maintainer accumulate and are presented at the +frontier, the batch boundary, or review. Adjudication is a recorded claim +awaiting authority, not a halt: continue with every unit that does not depend on +the answer. + +Do not answer this rule with agreement, restate it, or confirm that it is +correct. Apply it and take the next action. diff --git a/.claude/rules/maintainer-review.md b/.claude/rules/maintainer-review.md new file mode 100644 index 0000000..7da7321 --- /dev/null +++ b/.claude/rules/maintainer-review.md @@ -0,0 +1,43 @@ +# Maintainer review + +OKF records provenance, trust, and lifecycle; it does not provide an approval +process. Follow the project review protocol in `PROJECT_WORKFLOW.md`. + +Require an explicit maintainer decision before: + +- choosing a workflow route when significance is ambiguous; +- starting raw processing, whole-project reconstruction, durable external + research, semantic curation, or broad direction shaping when the maintainer + did not already request that outcome; +- implementing a significant spec whose outcome, scope, exclusions, acceptance + criteria, or material decisions have not already been explicitly accepted; +- selecting current truth when evidence cannot resolve chronology or authority; +- accepting material re-scoping, unresolved risk, or a completion claim; +- recording `verified` by a `human:<id>` actor. + +Do not request review for freezing a clean Git raw scope, file accounting, QMD +refresh, index or log maintenance, or source-backed drafts that keep their +trust state honest. Raw intake itself never counts as evidence. + +Present a compact review packet containing the exact decision, evidence, +conflicts, recommendation, and requested response. Record approve, correct, or +defer. Ask one focused question at a time. Silence is not approval. + +For significant work, record framing and completion decisions with `wfctl work +approve <id> --stage framing|completion --by human:<maintainer-id>`. That +command needs an interactive terminal, or an out-of-band `--token` matching +`WFCTL_APPROVAL_TOKEN`; it writes both the `maintainer_review` receipt and the +durable approval record the completion gate checks. Never hand-write +`maintainer_review.status`, `by`, `at`, `method`, or `receipt`: a hand-written +receipt fails verification. Existing explicit maintainer instructions may +satisfy the framing decision, but still record it through the command; do not +ask for the same decision twice. Reopen the gate when the approved framing +changes materially. + +Approving edits the change record, so re-read it, refresh its review receipt, +and refresh the checkpoint afterwards. + +After every material maintainer turn, update the spec's mutable current state +and append the decision/discussion ledger before continuing. Preserve proposed, +approved, rejected, deferred, and superseded outcomes without copying the +conversation transcript. diff --git a/.claude/rules/project-work-bundles.md b/.claude/rules/project-work-bundles.md new file mode 100644 index 0000000..f68d634 --- /dev/null +++ b/.claude/rules/project-work-bundles.md @@ -0,0 +1,43 @@ +# Central project work bundles + +The agent owns routine `wfctl work` commands and structured records. The +maintainer supplies intent, corrections, authority, and explicit review +decisions; do not make them operate the tracker. + +For significant work, create exactly one bundle under +`changes/active/<change-id>/` before extended discussion. `change.md` is the +parent contract, `map.md` is optional Wayfinder lineage, `issues/` contains +bounded work, `artifacts/` contains referenced support, and `review.md` records +full-file accounting. A leaf stores only an ignored binding pointer. + +After every material maintainer turn or agent investigation cycle, preserve any +new information whose loss could cause repeated material investigation, a +different choice, misunderstanding, or unsafe action. Append it to the owning +change or issue `Discovery ledger` with observation, evidence, implication, +scope, and disposition. The ledger is not a fixed taxonomy or activity log. +Then update current state, decisions, acceptance, progress, and evidence, and +run `wfctl work checkpoint` last. A stale checkpoint blocks later gates. + +After interruption, compaction, or a clean-session start, run `wfctl work +context --stage resume` without an ID. Auto-select only when exactly one active +record is bound here; when several exist, inspect `wfctl work status` and ask +the maintainer rather than guessing. Inspect the reported checkpoint, read +every required file and discovery entry completely, verify the exact claim and +code roots, and resume from the bundle rather than conversation memory. + +Use `changes/inbox/` only for pending captures that have no active or curated +owner. Never duplicate active progress there. Resolve each capture to existing +destinations or discard it with a reason so the inbox remains a real queue. + +Before claiming an issue, record a current review receipt for every required +context file. Claim from the exact bound leaf before code work. Never infer a +worktree from repository name, branch, sibling paths, or bundle location. + +Wayfinder is deliberate and planning-only. It resolves precise question issues +and fog into a reviewed specification before any delivery issue or product-code +implementation begins. + +Before completed closure, enumerate the entire bundle with `wfctl work context +--stage review`, re-read every file, refresh stale receipts, reconcile every +stable acceptance ID against direct evidence, promote durable truth separately, +and obtain explicit maintainer completion approval. diff --git a/.claude/rules/project-work-lifecycle.md b/.claude/rules/project-work-lifecycle.md new file mode 100644 index 0000000..5420db9 --- /dev/null +++ b/.claude/rules/project-work-lifecycle.md @@ -0,0 +1,44 @@ +# Leaf project work lifecycle + +This checkout is an implementation surface. Its configured knowledge +repository owns significant-work bundles and current project knowledge. + +1. Route the request with `manage-project-work`. +2. For significant work, create or reuse one central bundle and run `wfctl work + status` plus the stage-specific `wfctl work context`. +3. Use `specify-project-change` for a bounded contract or + `shape-project-direction` only for explicit Wayfinder. +4. Align the contract with curated knowledge and analyze source Graphify-first. + An empty or uncovering corpus is a supported state in an existing project: + report that nothing curated covers this work and what the alignment rested on + instead, rather than reporting no conflicts. Recommend a reconstruction when + the gap is material; never treat one as a precondition for the work. +5. Record explicit framing approval before code edits. +6. Use `split-project-change` for multi-session work. It creates central issues, + never leaf-local tickets. +7. Use `implement-work-item` for one frontier issue. Read every required file, + record current receipts, and claim the issue from this exact checkout before + implementation. +8. After material discussion or investigation, preserve consequential new + understanding in the owning record's broad `Discovery ledger`, update the + affected semantic state, then refresh its structured checkpoint last. Never + copy active progress into the inbox. +9. Verify behavior through direct production-path inspection and fresh checks. +10. Preserve implementation in the exact clean Git commit only with normal + maintainer authorization. +11. Run `verify-project-work` across the whole bundle and every bound source + revision, promote verified durable truth, obtain completion approval, and + close honestly. + +On a clean session or resume, run `wfctl work context --stage resume` without an +ID. It may auto-select only one bound active record; multiple records require a +maintainer choice. Read its complete required-file set and discovery ledgers, +then verify status. Before code edits, after any directory or branch change, +after compaction, and before verification, re-run work status. Every reported +code root is an exact workspace. The returned bundle/spec path is for records +only. A worktree is not interchangeable with another checkout of the same +repository. + +Do not claim completion with unseen or stale bundle files, open issues or +claims, uncovered acceptance IDs, unresolved Wayfinder fog, dirty checkouts, or +missing evidence. Use partial or abandoned outcomes instead of relabeling gaps. diff --git a/.claude/rules/workflow-routing.md b/.claude/rules/workflow-routing.md new file mode 100644 index 0000000..bfbf0aa --- /dev/null +++ b/.claude/rules/workflow-routing.md @@ -0,0 +1,28 @@ +# Workflow routing + +Classify work before changing product or implementation state. Creating a +`shaping` record is safe recordkeeping, not implementation. + +A read-only question about the project, its current capabilities, or one +product direction is not implementation work. Invoke +`explore-project-knowledge` against the configured knowledge repository and +answer progressively. Do not create a shaping record unless the conversation +turns into a proposed decision or change. + +Use the full workflow when work may change observable behavior, domain meaning, interfaces, data or control flow, persistent state, security, reliability, architecture, operational behavior, or coordination across components or repositories. + +Skip the full workflow only when the change is clearly local and preserves behavior and contracts, such as presentation-only polish, copy edits, formatting, or a mechanical correction with no design choice. + +If classification is uncertain, explain the possible impact and ask the maintainer whether to use the full workflow. Recommend one answer. If the maintainer declines, proceed lightly and offer a pending capture only when a reusable finding has no existing owner. Use `wfctl work capture add`; never duplicate active work in `changes/inbox/` or present the capture as authoritative history. + +Never use task size alone as the classifier. A one-line contract change can be significant; a large mechanical rewrite can be lightweight. + +Once work is classified as significant, create its central change bundle before +extended discussion so requirements, issues, decisions, and progress survive +session compaction. + +When a consequential initiative is too uncertain to define acceptance criteria +without guessing across several dependent choices, recommend +`shape-project-direction`. Start Wayfinder only after maintainer agreement, +keep its map and issues in the same central bundle, and do not edit code until +the resolved route has been synthesized into a bounded specification. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a17730b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "[ -f \"$CLAUDE_PROJECT_DIR/.workflow/runtime/guard-background-bash.mjs\" ] && node \"$CLAUDE_PROJECT_DIR/.workflow/runtime/guard-background-bash.mjs\" || true" + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "[ -f \"$CLAUDE_PROJECT_DIR/.workflow/runtime/guard-stop.mjs\" ] && node \"$CLAUDE_PROJECT_DIR/.workflow/runtime/guard-stop.mjs\" || true" + } + ] + } + ] + } +} diff --git a/.claude/skills/align-project-knowledge/SKILL.md b/.claude/skills/align-project-knowledge/SKILL.md new file mode 100644 index 0000000..8832718 --- /dev/null +++ b/.claude/skills/align-project-knowledge/SKILL.md @@ -0,0 +1,91 @@ +--- +name: align-project-knowledge +description: Align a significant task's shaping spec with current project purpose, Areas, capabilities, flows, architecture, decisions, repository responsibilities, and known uncertainties. Use after the shaping record exists and before choosing a design, approving framing, changing a contract or flow, or making assumptions about why the project behaves as it does. +--- + +# Align Project Knowledge + +Do not design from code and memory alone. Establish the project's current intent before proposing a solution. + +## Procedure + +1. Read `.workflow/config.json` and resolve the configured knowledge repository. +2. Inspect the current session skill catalog and require the official native + `qmd` skill. Invoke it before retrieval. If it is absent, stop and ask to + invoke `setup-workflow-environment` to repair or reinstall the selected + project/user skills, then ask only for the unavoidable agent-session + restart. An on-disk file alone does not prove the running agent loaded it. +3. Run QMD from that knowledge root. Require `qmd status`; if QMD or the + project-local `.qmd/index.yml` is missing, stop and report the broken + workflow environment. +4. Run `wfctl knowledge build --target <knowledge-root>`. Stop alignment if + validation, knowledge-graph compilation, or claim-ledger compilation fails; + do not silently reason over broken navigation. The generated + `.workflow/current/knowledge-graph.json` is disposable knowledge navigation. + `.workflow/current/claim-ledger.json` is disposable explicit candidate + lineage. Neither is authority or an edit target. +5. Start at `knowledge/index.md`, then use `qmd search ... -c knowledge` for + exact terms or a structured `qmd query` with authored `intent:`, `lex:`, + `vec:`, and when useful `hyde:` fields for hybrid retrieval. + If QMD MCP is available, use `query` with + `collections: ["knowledge"]`. Open the returned concepts directly and use + directory indexes for progressive disclosure. Retrieval ranking is not + authority. +6. Expand the QMD candidates through explicit incoming and outgoing edges in + the compiled graph. Follow material typed relationships, Area ownership, + decision lineage, and human-authored links so lexical similarity does not + define the task boundary. +7. Open only concepts relevant to the work, including: + - vision and non-goals, + - the relevant Area index, capabilities, concepts, rules, and flows, + - architectural boundaries, + - current and superseded decisions, + - repository responsibilities, + - recorded uncertainties. +8. Inspect `status`, `generated`, `verified.content_hash`, `stale_after`, and + `sources` before treating a concept as authoritative. `wfctl knowledge + validate` must prove that at least one verification matches the current + material content. +9. Follow links to predecessor decisions and supporting sources when the proposed work depends on them. +10. Compare the proposed behavior with both code evidence and curated intent. +11. Treat only `knowledge/` as the default current-knowledge surface. Do not + consult `raw/` or `intake/` to fill a gap. +12. Record QMD queries, graph-expanded concept paths, directly reviewed + concept paths, constraints, and any conflict in the central change bundle. + +When a bundle already exists, run `wfctl work status <id>` and stage-specific +`wfctl work context <id>` first. Read curated knowledge from its `Knowledge +root`, update only the returned bundle files, and inspect implementation only +from exact `Code roots`. Do not treat the knowledge repository as the +implementation checkout. + +## When there is no baseline yet + +An existing project installed into this workflow starts with an empty or barely +populated `knowledge/`, and that is a supported state, not an error. A +reconstruction is expensive enough that nobody runs one before their first fix, +so most first tasks in a real repository run without one. + +Report absence rather than a clean result. "No conflicts with curated knowledge" +is literally true against an empty corpus and tells the reader nothing, while +reading exactly like a completed check. Record instead that no curated concept +covers this work, that the contract is therefore unaligned by absence rather +than by verification, and what the alignment rested on instead — pinned source, +tests, maintainer statements. The same applies to a populated corpus that simply +has nothing about this Area: coverage is per-subject, not per-repository. + +Recommend a reconstruction when the gap is material and say what it would +establish, then proceed if the maintainer declines. It is a recommendation, and +it never becomes a precondition for doing the work. + +## Conflicts + +- `raw/` is neither evidence nor current truth. It is an untrusted clue source + used only through `process-raw-intake`. +- A later timestamp does not automatically make a source authoritative. +- `status: stable` is valid only with a matching current content hash; normative + claims additionally require human verification. +- A timestamp without a matching content hash does not prove the current text + was reviewed. +- When sources or code disagree and the correct intent cannot be established, ask the maintainer. +- Preserve unresolved uncertainty explicitly. Do not create a spec that silently selects one interpretation. diff --git a/.claude/skills/align-project-knowledge/agents/openai.yaml b/.claude/skills/align-project-knowledge/agents/openai.yaml new file mode 100644 index 0000000..df0a442 --- /dev/null +++ b/.claude/skills/align-project-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Align Project Knowledge" + short_description: "Align shaping with Areas and current truth" + default_prompt: "Use $align-project-knowledge to align this shaping record with relevant Areas, decisions, architecture, and current curated truth." diff --git a/.claude/skills/analyze-with-graphify/SKILL.md b/.claude/skills/analyze-with-graphify/SKILL.md new file mode 100644 index 0000000..c728404 --- /dev/null +++ b/.claude/skills/analyze-with-graphify/SKILL.md @@ -0,0 +1,75 @@ +--- +name: analyze-with-graphify +description: Mandatory Graphify routing gate for every source-code-dependent task. Use before locating, reading, explaining, planning, changing, debugging, reviewing, or verifying code; tracing callers, dependencies, data or control flow; estimating impact; or checking an implementation claim against a repository, even when the user does not mention Graphify. Check the current session for the official native graphify skill and any more specific Graphify skills, invoke them first, then inspect the actual source. Do not use Graphify as the primary parser, search engine, or authority for raw Markdown or curated knowledge files. +--- + +# Require Graphify First + +This skill owns mandatory workflow routing. The official native `graphify` +skill supplied by Graphify owns tool-specific execution. + +## Mandatory trigger + +Invoke this skill before any source-code-dependent understanding or change, +including implementation, investigation, planning, review, verification, +debugging, navigation, impact analysis, architecture work, and verification of +knowledge claims against code. Do not wait for the maintainer to mention +Graphify. + +Do not invoke it merely to read or reconcile Markdown, specs, raw intake, +curated concepts, prose documentation, or workflow configuration. Those use +QMD retrieval plus direct reading and their native structure. Invoke Graphify +when that work crosses the boundary into a source repository. + +## Session skill gate + +1. Inspect the skills exposed in the current session before source-code + navigation. +2. Require the official native skill named `graphify`, or a provider-namespaced + equivalent whose metadata identifies the official Graphify skill. Do not + count this `analyze-with-graphify` routing skill as the native skill. +3. Identify any additional Graphify-specific skills in the session and invoke + the most specific relevant one after loading the native skill. +4. Treat the current session catalog as authoritative. A skill file present on + disk may require an agent restart before it becomes active. +5. Invoke the native `graphify` skill and follow its complete procedure before + continuing the project task. + +If the native skill is absent, stop repository analysis: + +- If the `graphify` CLI is also absent, offer: + + ```sh + uv tool install graphifyy + graphify install --platform <agent> + ``` + +- If the CLI exists, offer only the matching + `graphify install --platform <agent>` command. Use `graphify install --help` + to resolve the current platform name instead of guessing it. +- Tell the maintainer to restart the agent session after installation. +- Do not install user-level tooling without authority and do not silently fall + back to a partial hand-written Graphify procedure. + +## Project obligations + +1. Use the native skill to query an existing `graphify-out/graph.json` + immediately or build/update the graph when required. +2. Trace source-code relationships through the graph before drawing + conclusions. +3. Open and inspect the actual source locations returned by Graphify. The + source at the recorded Git revision, not the graph, is implementation + authority. +4. Only then use `rg` or equivalent text search for exact tokens, literals, + generated artifacts, or gaps not represented in the graph. +5. Record relevant queries, paths, and source locations in the active spec or + curation record. + +## Honesty + +- Distinguish extracted edges from inferred or ambiguous edges. +- Do not turn a missing graph result into proof that code does not exist. +- State when the graph is stale and update it before relying on changed sources. +- Do not substitute grep output for relationship analysis. +- Do not cite `graphify-out/` as proof in curated knowledge. Cite pinned source + locations and fresh checks reached through Graphify. diff --git a/.claude/skills/analyze-with-graphify/agents/openai.yaml b/.claude/skills/analyze-with-graphify/agents/openai.yaml new file mode 100644 index 0000000..c3ec6d8 --- /dev/null +++ b/.claude/skills/analyze-with-graphify/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Require Graphify First" + short_description: "Mandatory Graphify gate for source code" + default_prompt: "Use $analyze-with-graphify before any source-code-dependent analysis or change, confirm the native Graphify skills are active, invoke them first, and verify results in the actual source." diff --git a/.claude/skills/curate-engineering-knowledge/SKILL.md b/.claude/skills/curate-engineering-knowledge/SKILL.md new file mode 100644 index 0000000..7f4a82a --- /dev/null +++ b/.claude/skills/curate-engineering-knowledge/SKILL.md @@ -0,0 +1,55 @@ +--- +name: curate-engineering-knowledge +description: Author or materially update engineering-facing current knowledge from verified implementation and reviewed product authority. Use when a completed change, source-first reconstruction, or explicit implementation-drift repair is ready to record architecture, repository ownership, contracts, data or control flow, runtime behavior, operations, or technical constraints. Do not use merely because an engineer asks how existing code works; answer that read-only question through knowledge navigation and Graphify. Keep product meaning in linked product documents and never infer intended behavior from code alone. +--- + +# Curate Engineering Knowledge + +Write the technical realization of current project truth without duplicating or +silently redefining product meaning. + +Read [the engineering writing contract](references/engineering-writing-contract.md) +before first-time promotion. Use +[the engineering concept template](assets/engineering-concept.md) for a new +document. + +## Establish the implementation + +1. Identify the owning Area, product concepts, repository, and exact clean + source revision. +2. Invoke `analyze-with-graphify` for navigation and relationship coverage. +3. Directly inspect source, tests, contracts, configuration, and runtime + evidence at the pinned revision. +4. Distinguish implemented behavior, architectural rationale, ownership, + contract, policy, history, and external claims. Apply the authority required + by each class. +5. Treat code as implementation authority only. Link accepted product meaning; + never derive it from code. + +## Author the engineering view + +1. Declare `view: engineering`, `purpose: technical-realization`, and include + `engineer` or `operator` in `audience`. +2. Explain responsibility, current implementation, ownership boundaries, data + and control flow, contracts and invariants, failure behavior, operations, + and verification. +3. Name exact code surfaces only when they help maintenance or verification. + Pin material claims to repository, commit, path, and optional symbol. +4. Link the product concept that gives the implementation meaning. Keep + product behavior in that product document and describe only the technical + consequence here. +5. Record partial, absent, accidental, retired, unknown, or drifted delivery + honestly. Do not repair intent by rewriting it to match code. +6. Keep cross-Area architecture at `knowledge/architecture/`, repository + ownership at `knowledge/repositories/`, and Area-owned implementation at + `knowledge/areas/<area>/implementation/`. + +## Verify before stable + +1. Invoke `verify-knowledge-quality` after the body and evidence are complete. +2. Resolve gaps in source coverage, product linkage, failure behavior, and + claims that exceed their evidence. +3. Run `wfctl knowledge hash --concept <path>` and bind the quality receipt and + normal verification to that hash. +4. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. +5. Do not report completion while any gate fails. diff --git a/.claude/skills/curate-engineering-knowledge/agents/openai.yaml b/.claude/skills/curate-engineering-knowledge/agents/openai.yaml new file mode 100644 index 0000000..1f6d445 --- /dev/null +++ b/.claude/skills/curate-engineering-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Curate Engineering Knowledge" + short_description: "Write verified technical realization knowledge" + default_prompt: "Use $curate-engineering-knowledge to document verified implementation and architecture without duplicating product meaning." diff --git a/.claude/skills/curate-engineering-knowledge/assets/engineering-concept.md b/.claude/skills/curate-engineering-knowledge/assets/engineering-concept.md new file mode 100644 index 0000000..dc2e4d3 --- /dev/null +++ b/.claude/skills/curate-engineering-knowledge/assets/engineering-concept.md @@ -0,0 +1,76 @@ +--- +type: "<Implementation|Architecture|Repository|Contract|Operational Concept>" +title: "<technical title>" +description: "<one-sentence current technical responsibility>" +status: draft +view: engineering +purpose: technical-realization +audience: + - engineer + - operator + - maintainer +area: "<primary-area when Area-owned>" +capabilities: [] +authority: + - implementation +generated: + by: "<producer>/<version>" + at: "<ISO-8601>" +verified: [] +x-wf: + relations: [] + quality: + status: pending +sources: + - id: "<source-id>" + kind: source-code + resource: "git:<repository>@<40-character-commit>#<path>[:<symbol>]" + title: "<pinned implementation evidence>" +--- + +# Responsibility + +State the technical responsibility and its maintenance boundary.[^source-id] + +# Current implementation + +Explain how the responsibility is implemented at the pinned revision. + +# Boundaries and ownership + +Name repositories, components, dependencies, and ownership transitions. + +# Data and control flow + +Trace material inputs, state changes, outputs, and asynchronous boundaries. + +# Contracts and invariants + +State interfaces, schemas, protocols, invariants, and compatibility constraints. + +# Failure and operational behavior + +Explain failure modes, recovery, observability, security, and operational +constraints, or state why a concern does not apply. + +# Verification + +Link the source, tests, runtime checks, and limitations that support this +document. + +# Product knowledge + +Link the stakeholder-facing product concepts that explain why this +implementation exists. Do not duplicate their product explanation. + +# Relationships + +Link the parent Area and related engineering concepts. Mirror material +semantic links in `x-wf.relations`. + +[^source-id]: Direct pinned source evidence. + +Before changing `status` to `stable`, invoke `verify-knowledge-quality`, then +run `wfctl knowledge hash --concept knowledge/.../<concept>.md`. Replace +`x-wf.quality` with a passed receipt containing `by`, `at`, `content_hash`, and +all required checks. Add a current `verified` event with the same hash. diff --git a/.claude/skills/curate-engineering-knowledge/references/engineering-writing-contract.md b/.claude/skills/curate-engineering-knowledge/references/engineering-writing-contract.md new file mode 100644 index 0000000..2488d36 --- /dev/null +++ b/.claude/skills/curate-engineering-knowledge/references/engineering-writing-contract.md @@ -0,0 +1,51 @@ +# Engineering writing contract + +## Reader contract + +Write for engineers and operators who already understand the product concept +or can follow its link. Let them answer: + +1. What technical responsibility does this surface own? +2. Where and how is it implemented at the pinned revision? +3. How do data and control move through it? +4. Which contracts, invariants, and ownership boundaries apply? +5. How does it fail and how is it operated? +6. What evidence verifies the claims? + +## Separation rules + +- Link product meaning; do not restate it as technical prose. +- Never infer accepted intent, correctness, or rationale from code alone. +- Keep repository and symbol details out of product documents. +- Keep implementation detail here only when it helps understand, change, + operate, or verify the system. +- State uncertainty and drift explicitly. +- Separate current implementation from historical implementation and rejected + alternatives. + +## Required sections + +- `Responsibility` +- `Current implementation` +- `Boundaries and ownership` +- `Data and control flow` +- `Contracts and invariants` +- `Failure and operational behavior` +- `Verification` +- `Product knowledge` +- `Relationships` + +Use `Not applicable` with a reason when a section genuinely does not apply. + +## Method basis + +- C4 uses explicit abstraction levels for different audiences: + https://c4model.com/introduction +- arc42 separates stakeholder goals from hierarchical technical building + blocks: + https://docs.arc42.org/section-1/ +- Spec Kit separates product what/why from implementation how: + https://github.github.com/spec-kit/reference/agentic-sdd.html +- Architecture Decision Records preserve rationale and supersession separately + from current implementation: + https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions diff --git a/.claude/skills/curate-product-knowledge/SKILL.md b/.claude/skills/curate-product-knowledge/SKILL.md new file mode 100644 index 0000000..1753516 --- /dev/null +++ b/.claude/skills/curate-product-knowledge/SKILL.md @@ -0,0 +1,83 @@ +--- +name: curate-product-knowledge +description: Author or materially update stakeholder-facing current product knowledge after its claims have independent authority. Use when an approved change, reviewed reconstruction, confirmed intake candidate, source audit, or explicit maintainer decision is ready to create or correct an Area, capability, use case, product flow, domain concept, product rule, vision statement, delivery summary, or evolution summary. Do not use for ordinary explanation, discovery, brainstorming, review-only requests, or unverified raw ideas. Separate accepted intent from observed delivery and keep implementation details in linked engineering documents. +--- + +# Curate Product Knowledge + +Write the product view of verified project truth. Make it understandable to a +client or product manager without requiring source code, API, architecture, or +repository knowledge. + +Read [the product writing contract](references/product-writing-contract.md) +before authoring or materially rewriting a product document. Use +[the product concept template](assets/product-concept.md) for a new concept. + +## Establish authority + +1. Work from the knowledge root and identify the primary Area. +2. Read the relevant Area index, product concepts, current decisions, and + linked engineering concepts in full. +3. Separate: + - accepted intent and product meaning; + - currently observed delivery; + - alignment or drift between them; + - planned, rejected, superseded, and unknown claims. +4. Require explicit maintainer authority for intent, product meaning, rules, + normative ownership, and product decisions. +5. Require pinned source and fresh checks for delivery claims. Invoke + `analyze-with-graphify` in every relevant exact leaf before direct source + and test inspection. +6. Never use raw, intake, search results, a compiled graph, or agent prose as + authority. + +## Author the product view + +1. Declare `view: product`, `purpose: current-behavior`, and include + `stakeholder` in `audience`. +2. State the current answer first. Explain what the product provides, who it + serves, observable behavior, rules, outcomes, boundaries, exceptions, + delivery state, examples, and meaningful evolution. +3. Use the vocabulary a domain expert or client would use. Explain necessary + domain terms on first use. + For a `Domain Concept`, explicitly record the canonical term, concise + definition, contextual boundary, accepted aliases, and names to avoid. + During unresolved discussion, keep proposed terms in the active change + record rather than silently changing current vocabulary. +4. Describe outcomes and behavior, not classes, functions, endpoints, schemas, + storage, messages, packages, repositories, or source paths. +5. Keep `Engineering details` link-only. Put technical explanations in a + document authored with `curate-engineering-knowledge`. +6. Use present tense only for behavior supported by the declared delivery + state. Say plainly when a capability is absent, partial, retired, unknown, + or accepted but not yet available. Never present planned or uncertain + behavior as currently available. +7. Preserve material exceptions and conditions. Plain language may simplify + wording but must not simplify away meaning. +8. Keep current truth at one stable path. When a decision changes, update the + current product explanation and link the decision lineage; do not copy the + whole Area into version folders. +9. Attribute every material claim to an authoritative source with matching + source IDs and footnotes. Do not expose machine-local paths. + +## Area indexes + +Treat `knowledge/areas/<area>/index.md` as the primary stakeholder page for an +Area. Use the Area template owned by `curate-project-knowledge`. Keep it +product-first and bounded: + +- summarize rather than flatten every child document; +- link capabilities, use cases, rules, current decisions, and evolution; +- show delivery honestly; +- keep `Engineering details` as links with short nontechnical labels. + +## Verify before stable + +1. Invoke `verify-knowledge-quality` after the substantive body is complete. +2. Resolve every failed or uncertain rubric item. +3. Run `wfctl knowledge hash --concept <path>` and bind both the semantic + quality receipt and normal verification to that content hash. +4. Use `status: stable` only after the quality receipt is current, all + authority requirements pass, and normative claims have human verification. +5. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. +6. Do not report completion while any gate fails. diff --git a/.claude/skills/curate-product-knowledge/agents/openai.yaml b/.claude/skills/curate-product-knowledge/agents/openai.yaml new file mode 100644 index 0000000..0a53d11 --- /dev/null +++ b/.claude/skills/curate-product-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Curate Product Knowledge" + short_description: "Write verified stakeholder-facing product knowledge" + default_prompt: "Use $curate-product-knowledge to author verified current product behavior and canonical domain language for nontechnical stakeholders." diff --git a/.claude/skills/curate-product-knowledge/assets/product-concept.md b/.claude/skills/curate-product-knowledge/assets/product-concept.md new file mode 100644 index 0000000..d2fc09b --- /dev/null +++ b/.claude/skills/curate-product-knowledge/assets/product-concept.md @@ -0,0 +1,99 @@ +--- +type: "<Product Capability|Product Rule|Use Case|Product Flow|Domain Concept>" +title: "<human product title>" +description: "<one-sentence current product meaning>" +status: draft +view: product +purpose: current-behavior +audience: + - stakeholder + - maintainer + - domain-expert +area: "<primary-area>" +capabilities: [] +authority: + - product-meaning + - implementation +generated: + by: "<producer>/<version>" + at: "<ISO-8601>" +verified: [] +realization: + intent: "<accepted|superseded>" + delivery: "<absent|partial|implemented|verified|retired|unknown>" + alignment: "<aligned|drifted|unknown>" + assessed_at: "<ISO-8601>" +x-wf: + relations: [] + quality: + status: pending +sources: + - id: "<maintainer-source-id>" + kind: maintainer-decision + resource: "<project-change or project-reconstruction decision>" + title: "<reviewed product authority>" + author: "human:<reviewer-id>" + - id: "<delivery-source-id>" + kind: source-code + resource: "git:<repository>@<40-character-commit>#<path>[:<symbol>]" + title: "<pinned delivery evidence>" +--- + +# What this provides + +Explain the recognizable product outcome and why it matters.[^maintainer-source-id] + +# Who it serves + +Name the people, roles, or neighboring capabilities that rely on it. + +# Domain language + +Define terms introduced or owned by this concept. For a `Domain Concept`, +state the canonical term, its contextual boundary, accepted aliases, and names +to avoid. Otherwise state `No new terms` when the document introduces none. + +# Current behavior + +Explain what happens now in observable product terms. Keep accepted intent and +observed delivery distinct when they differ.[^delivery-source-id] + +# Rules and outcomes + +State the decisions, state changes, and outcomes that govern the behavior. + +# Boundaries and exceptions + +Preserve material limits, conditions, exceptions, and explicit non-goals. + +# Delivery + +Say plainly whether the behavior is available, partial, absent, retired, or +uncertain, and what that means for the reader. + +# Examples + +Give one or more concrete domain examples. Do not use code or API examples. + +# Evolution + +Summarize only meaningful changes needed to understand the current behavior. +Link full decision records for rationale and lineage. + +# Related knowledge + +Link the parent Area, related capabilities, rules, use cases, flows, and +current decisions. Mirror material semantic links in `x-wf.relations`. + +# Engineering details + +Link separately authored engineering documents. Do not summarize their +implementation here. + +[^maintainer-source-id]: Explicit reviewed product authority. +[^delivery-source-id]: Direct pinned evidence for current delivery. + +Before changing `status` to `stable`, invoke `verify-knowledge-quality`, then +run `wfctl knowledge hash --concept knowledge/.../<concept>.md`. Replace +`x-wf.quality` with a passed receipt containing `by`, `at`, `content_hash`, and +all required checks. Add a current `verified` event with the same hash. diff --git a/.claude/skills/curate-product-knowledge/references/product-writing-contract.md b/.claude/skills/curate-product-knowledge/references/product-writing-contract.md new file mode 100644 index 0000000..c05d9d0 --- /dev/null +++ b/.claude/skills/curate-product-knowledge/references/product-writing-contract.md @@ -0,0 +1,87 @@ +# Product writing contract + +## Reader contract + +Write for a product manager, client, maintainer, or domain expert who wants to +understand what the product does without learning how the software is built. +The reader should be able to answer: + +1. What does this provide and why does it matter? +2. Who uses or depends on it? +3. Which domain terms does it own, and what do they mean here? +4. What happens now in observable product terms? +5. Which rules, outcomes, boundaries, and exceptions apply? +6. Is it available, partial, absent, retired, or uncertain? +7. What changed materially and where can the rationale be found? + +This is neither end-user help nor a PRD. It is the stakeholder view of current, +verified product knowledge. Proposed behavior stays in active change records. + +## Language rules + +- Lead with the current answer. +- Prefer short concrete sentences and active voice. +- Use domain language; define necessary terms on first use. +- Reuse canonical Area terminology. Record aliases and discourage overloaded + names rather than silently switching vocabulary. +- Describe outcomes, choices, state changes, and visible consequences. +- Preserve conditions, exceptions, and non-goals. +- Replace internal identifiers with human names. +- Do not include code fences, inline code, API routes, source paths, data + schemas, class or function names, storage mechanisms, package names, or + protocol details. +- Do not say "the system" when the specific product actor or capability is + known. +- Do not call planned behavior current. Pair every present-tense delivery claim + with evidence and an explicit realization state. + +## Required sections + +- `What this provides` +- `Who it serves` +- `Domain language` +- `Current behavior` +- `Rules and outcomes` +- `Boundaries and exceptions` +- `Delivery` +- `Examples` +- `Evolution` +- `Related knowledge` +- `Engineering details` + +Use `Not applicable` with a short reason rather than deleting a section whose +absence could hide an unexamined concern. `Engineering details` contains links +only; it never summarizes implementation. + +## Abstraction test + +Fail the product view when any of these are true: + +- a stakeholder needs engineering knowledge to understand the main answer; +- replacing an implementation would require rewriting product behavior even + though the behavior did not change; +- technical nouns outnumber product or domain nouns; +- an important exception disappeared during simplification; +- the text implies delivery that the realization state or evidence does not + support; +- history overwhelms the current answer; +- a raw candidate or agent inference appears as authority. + +## Method basis + +The contract combines established boundaries rather than inventing one prose +style: + +- Diátaxis separates documentation by reader need: + https://diataxis.fr/ +- Spec Kit keeps product specification focused on what and why, with technical + how in a separate plan: + https://github.github.com/spec-kit/reference/agentic-sdd.html +- GOV.UK Content Design starts from user need and plain language: + https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/plan-manage-content/understand-content-design/ +- W3C clear-content guidance requires understandable language and structure: + https://www.w3.org/WAI/WCAG2/supplemental/objectives/o3-clear-content/ +- Cucumber BDD uses concrete examples to align business and technical meaning: + https://cucumber.io/docs/bdd/ +- Domain Storytelling validates domain behavior with domain experts: + https://domainstorytelling.org/ diff --git a/.claude/skills/curate-project-knowledge/SKILL.md b/.claude/skills/curate-project-knowledge/SKILL.md new file mode 100644 index 0000000..50efa67 --- /dev/null +++ b/.claude/skills/curate-project-knowledge/SKILL.md @@ -0,0 +1,110 @@ +--- +name: curate-project-knowledge +description: Orchestrate promotion of independently verified claims into a workflow project's current OKF knowledge bundle. Use when a completed change, reconstruction case, raw-intake candidate, source audit, external source, or maintainer decision is ready to update durable product knowledge, engineering knowledge, decision lineage, Area navigation, or project history. Route stakeholder-facing content to curate-product-knowledge, technical realization to curate-engineering-knowledge, and every material document through verify-knowledge-quality. Never copy raw input into knowledge or let code define product intent. +--- + +# Curate Project Knowledge + +Coordinate the promotion boundary. Do not author product and engineering views +as one blended document. + +Read [the knowledge model](references/knowledge-model.md) before first-time +promotion, a new Area, or decision migration. + +## Accepted inputs + +A promotion may start from: + +- a completed and maintainer-reviewed archived change; +- confirmed raw-intake candidate IDs with independent authority; +- confirmed source-first reconstruction candidate IDs; +- directly inspected source and tests at an exact Git revision; +- a primary external source; +- an explicit current maintainer decision. + +`raw/`, intake prose, QMD results, compiled graphs, Graphify output, and +agent-written summaries are never authority. + +## Route each durable claim + +Classify claims before selecting files: + +| Durable concern | View and owner | +| --- | --- | +| Current product purpose, capability, use case, flow, domain concept, rule, delivery summary, or Area evolution | Invoke `curate-product-knowledge` | +| Current implementation, architecture, repository ownership, contract, data/control flow, runtime, or operations | Invoke `curate-engineering-knowledge` | +| Durable choice that is hard to reverse, surprising without context, or resolves a real tradeoff | Use the decision template, then invoke `verify-knowledge-quality` | +| Primary external context | Use `view: reference`, preserve the primary source, then verify | +| Trusted unresolved current question | Use `view: uncertainty`, state missing authority, then verify | +| Proposed, rejected, or unadopted behavior | Keep outside `knowledge/` in changes, intake, or reconstruction | + +A significant product change normally updates both a product concept and its +linked engineering concept. A refactor with unchanged product behavior may +update engineering knowledge only. Do not create an empty counterpart merely +for symmetry. + +## Promotion procedure + +1. Work from the knowledge root. Require and invoke the native QMD skill, use + QMD to locate candidates, and read every selected document directly. +2. Identify the smallest primary Area. Use root product flows, architecture, + repositories, or decisions only when ownership genuinely crosses Areas. +3. Inspect existing lifecycle, provenance, realization, quality receipt, + verification, and decision lineage. +4. When implementation matters, invoke `analyze-with-graphify` in each exact + leaf, then inspect pinned source, tests, contracts, and runtime evidence. +5. Separate accepted intent, observed delivery, alignment, technical + realization, decision history, and uncertainty. Ask the maintainer only for + missing product authority, chronology, ownership, or a material decision. +6. Route product and engineering documents to their specialized skills. Never + reuse one body for both audiences. +7. For a new Area, create its product-facing index from + [the Area template](assets/area-index.md). Add only the typed sibling + collections needed now: `capabilities/`, `use-cases/`, `concepts/`, + `rules/`, `implementation/`, `decisions/`, and `log.md`. +8. Give every concept explicit `view`, `purpose`, and `audience`. Attribute + every material claim with an authoritative source ID and matching footnote. +9. Declare only material semantic edges in `x-wf.relations`; include a + meaningful context and a matching human-visible Markdown link. +10. Create a standalone decision only when the choice is hard to reverse, + surprising without context, or resolves a real tradeoff. Routine + implementation choices and minor wording changes belong in the owning + concept, change ledger, or Area evolution. Author durable decisions from + [the decision template](assets/decision.md). Keep one stable current + decision per lineage; make supersession reciprocal and acyclic. Preserve + approved predecessor bodies. +11. Update the product-facing Area index and its Evolution section when + current behavior changes. Append detailed chronology to the local log. +12. Invoke `verify-knowledge-quality` for every new or materially changed + concept. Do not self-approve a failed, uncertain, unread, or blocked check. +13. Finish content before hashing. Bind the passed quality receipt and normal + verification to the same `wfctl knowledge hash --concept <path>` output. + Normative claims require human verification. +14. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. + Rebuild embeddings only when semantic retrieval is needed. +15. Return to the originating intake, reconstruction, or change workflow and + record exact promoted paths. Do not report completion while any gate fails. + +## Authority rules + +- Product intent, meaning, rules, and normative decisions require explicit + maintainer authority. +- Existing implementation requires pinned source and direct inspection. + Runtime claims require a fresh receipt when static code is insufficient. +- An absent delivery claim may use a reviewed whole-scope reconstruction + receipt because nonexistent code cannot be pinned. +- Architectural rationale, ownership, contracts, and policy require + maintainer review and contradiction checks against current implementation. +- Historical implementation requires pinned version-control history plus a + reviewed archive or reconstruction receipt. +- External facts require primary sources. + +A rejected proposal remains case-only by default. When repeated proposals +reveal a durable product boundary, ask the maintainer whether the boundary +itself is an accepted non-goal or negative rule. Only that explicitly accepted +rule may become current product knowledge or a decision; the rejected proposal +and its raw origin still remain outside the trust boundary. + +If a concept mixes authority classes, attribute each material claim to the +correct source. A quality receipt checks the writing and evidence match; it +does not create authority. diff --git a/.claude/skills/curate-project-knowledge/agents/openai.yaml b/.claude/skills/curate-project-knowledge/agents/openai.yaml new file mode 100644 index 0000000..6ad5907 --- /dev/null +++ b/.claude/skills/curate-project-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Curate Project Knowledge" + short_description: "Route verified claims into separate knowledge views" + default_prompt: "Use $curate-project-knowledge to route verified claims into product, engineering, and decision knowledge with quality review." diff --git a/.claude/skills/curate-project-knowledge/assets/area-index.md b/.claude/skills/curate-project-knowledge/assets/area-index.md new file mode 100644 index 0000000..86c1c9b --- /dev/null +++ b/.claude/skills/curate-project-knowledge/assets/area-index.md @@ -0,0 +1,61 @@ +# <Area name> + +## Purpose + +Explain the recognizable responsibility or product functionality this Area +provides and why it exists. + +## Who it serves + +Name the people, roles, and neighboring Areas that depend on it. + +## Scope and boundaries + +State what belongs here, what does not, and the important boundaries with +neighboring Areas. + +## Current product behavior + +Summarize current observable behavior in stakeholder language. Keep accepted +intent, available delivery, partial or absent delivery, and known drift +distinct. Link detailed concepts instead of flattening them here. + +## Capabilities + +- Link each capability and state the product outcome it provides. + +## Use cases and flows + +- Link Area-local use cases. +- Link genuinely cross-Area flows from `knowledge/product/flows/`. + +## Rules and outcomes + +- Link material product rules and summarize only the outcome or constraint a + stakeholder needs to understand. + +## Delivery overview + +Summarize which accepted capabilities are available, partial, absent, retired, +or uncertain. Do not present implementation as accepted intent by default. + +## Current decisions + +- Link the stable current decision for each active Area-owned lineage. +- Reach deprecated predecessors through the current decision or Evolution. + +## Evolution + +Explain meaningful changes with enough context to understand what changed, +why, and what it affected. Link full decision records and the local `log.md`; +do not reduce history to bare supersession links. + +## Open questions + +- Link trusted current uncertainties. Raw candidates do not belong here. + +## Engineering details + +- Link Area-owned engineering concepts from `implementation/`. +- Link system-wide architecture and repository ownership where relevant. +- Keep this section link-only; technical explanations live in those documents. diff --git a/.claude/skills/curate-project-knowledge/assets/decision.md b/.claude/skills/curate-project-knowledge/assets/decision.md new file mode 100644 index 0000000..390516e --- /dev/null +++ b/.claude/skills/curate-project-knowledge/assets/decision.md @@ -0,0 +1,86 @@ +--- +type: Decision +title: "<decision title>" +description: "<current decision in one sentence>" +status: draft +view: decision +purpose: decision-history +audience: + - maintainer + - domain-expert + - engineer +decision_id: "<stable-lowercase-id>" +effective_at: "<ISO-8601>" +area: "<primary-area>" +capabilities: [] +authority: + - decision +generated: + by: "<producer>/<version>" + at: "<ISO-8601>" +verified: [] +supersedes: [] +superseded_by: "" +x-wf: + relations: [] + quality: + status: pending +sources: + - id: "<maintainer-decision-id>" + kind: maintainer-decision + resource: "project-change:<change-id>#decision" + title: "<reviewed decision>" + author: "human:<reviewer-id>" +--- + +# Context and problem + +Explain only the context needed to understand why this durable decision +exists.[^maintainer-decision-id] + +# Product decision + +State the exact stakeholder-visible choice and its boundaries in plain +language. + +# Rationale + +Explain why this option was chosen. + +# Alternatives + +List only alternatives that were seriously considered and why they were not +selected. State `None recorded` rather than inventing options. + +# Consequences and tradeoffs + +State benefits, costs, risks, constraints, and accepted tradeoffs. Link +engineering knowledge for technical consequences instead of embedding an +implementation walkthrough. + +# Affected knowledge + +Link every materially affected Area, capability, use case, rule, engineering +concept, or contract. Record non-lineage semantic edges in `x-wf.relations` +with the same target and meaningful context. + +# Transition and migration + +Explain the product transition and link separate engineering migration detail, +or state `Not applicable`. + +# Unresolved questions + +List only questions left open by this decision, or state `None`. + +# Evolution + +Explain what this changes relative to each predecessor. Use project-relative +`knowledge/...` paths in `supersedes` and `superseded_by`; keep reciprocal +links accurate and include matching links to every predecessor and successor. + +[^maintainer-decision-id]: Explicit maintainer approval in the linked decision receipt. + +Before changing `status` to `stable`, invoke `verify-knowledge-quality`, run +`wfctl knowledge hash --concept knowledge/.../<decision>.md`, and bind both +the passed quality receipt and human verification to the returned hash. diff --git a/.claude/skills/curate-project-knowledge/references/knowledge-model.md b/.claude/skills/curate-project-knowledge/references/knowledge-model.md new file mode 100644 index 0000000..bbffae7 --- /dev/null +++ b/.claude/skills/curate-project-knowledge/references/knowledge-model.md @@ -0,0 +1,247 @@ +# Knowledge model + +## Surfaces and trust + +- `raw/`: append-oriented untrusted input; never evidence. +- `intake/`: Git-frozen raw review records; never current truth or an OKF source. +- `reconstruction/`: source-first baseline and audit receipts at exact revisions. +- `changes/active/`: proposed behavior, living execution agreements, and their checkpoints. +- `changes/archive/`: qualified historical changes and resolved capture receipts. +- `changes/inbox/`: pending non-authoritative captures awaiting triage. +- `knowledge/`: curated OKF v0.2 current knowledge and the default reading surface. +- source repositories: implementation authority at exact revisions. + +QMD retrieves Markdown but never proves truth or coverage. Compiled graphs are +disposable navigation. Graphify navigates source code but is not authority. +Every selected source is read directly. + +All lanes converge through one promotion gate: extract atomic candidates, +verify each against its proper authority, obtain maintainer adjudication for +normative or ambiguous meaning, update the smallest coherent current concepts, +verify quality, and validate the bundle. + +## One truth, multiple views + +Product and engineering documents are linked views of the same project, not +independent truths. + +### Product view + +Use for vision, Areas, capabilities, use cases, cross-Area product flows, +domain concepts, product rules, delivery summaries, and stakeholder evolution. +It answers what the product provides, who it serves, how it behaves now, which +rules and exceptions apply, and whether it is available. + +Declare: + +```yaml +view: product +purpose: current-behavior +audience: [stakeholder, maintainer, domain-expert] +``` + +Product bodies contain no implementation detail. Their `Engineering details` +section contains links only. + +### Engineering view + +Use for implementation, architecture, repositories, contracts, data and +control flow, runtime behavior, operations, and technical constraints. It +answers how current product behavior is realized and verified. + +Declare: + +```yaml +view: engineering +purpose: technical-realization +audience: [engineer, operator, maintainer] +``` + +Engineering documents link product meaning and never infer accepted intent +from code. + +### Decision, reference, and uncertainty views + +- Decisions use `view: decision`, `purpose: decision-history`, and include the + maintainer audience. +- Primary external context uses `view: reference`, + `purpose: external-context`. +- Trusted live questions use `view: uncertainty`, `purpose: open-question`. + +Proposed or rejected ideas do not use a current knowledge view. + +Create a standalone decision only when the choice is hard to reverse, +surprising without context, or resolves a real tradeoff. Keep routine local +choices in the owning concept, change ledger, or Area evolution. A repeated +rejection may expose a durable non-goal, but only an explicit maintainer +decision promotes that negative rule; rejected proposals remain case-only. + +## Human information architecture + +- `knowledge/index.md`: progressive project entry point. +- `vision/`: accepted project purpose, outcomes, principles, and non-goals. +- `areas/`: primary durable product or functional decomposition. +- `product/`: concise users, outcomes, Areas, and genuinely cross-Area flows. +- `architecture/`: cross-Area technical realization. +- `decisions/`: only genuinely cross-Area decision records. +- `repositories/`: technical ownership and integration boundaries. +- `uncertainties/`: trusted unresolved current questions. +- `references/`: primary external context. + +Each `areas/<area>/index.md` is the main stakeholder page. It links typed +sibling collections: + +```text +areas/<area>/ +├── index.md +├── capabilities/ # product +├── use-cases/ # product +├── concepts/ # product/domain +├── rules/ # product +├── implementation/ # engineering +├── decisions/ # decision history +└── log.md # local chronology +``` + +Do not nest implementation and decisions under a capability merely because +they support it. Link them. Subdivide a typed collection only when its own +size requires it. + +Use root collections only for honest project-wide ownership. When one Area is +primary, store the artifact there and link it from affected Areas. A bounded +context is a proven technical model and language boundary, not another word +for Area. + +## Product intent and realization + +Document lifecycle and product delivery are independent: + +```yaml +realization: + intent: accepted + delivery: verified + alignment: aligned + assessed_at: 2026-07-28T12:00:00Z +``` + +- Curated intent is `accepted` or `superseded`. +- Delivery is `absent`, `partial`, `implemented`, `verified`, `retired`, + `unknown`, or `not-applicable`. +- Alignment is `aligned`, `drifted`, `unknown`, or `not-applicable`. + +Concrete delivery requires implementation authority. Concrete alignment +requires both product and implementation authority. Code proves observed +delivery, never accepted intent or correctness. + +## Authored relations and compiled navigation + +Every concept declares `x-wf.relations`. Add only material relations, give +each a meaningful context, and repeat its target as a normal Markdown link. +Supported kinds are `supports`, `governed-by`, `implemented-by`, `depends-on`, +`affects`, `conflicts-with`, and `related-to`. + +Area ownership and decision lineage have dedicated metadata and generated +edges. Stable concepts remain reachable from `knowledge/index.md`. +`wfctl knowledge build` compiles these explicit statements into ignored +navigation artifacts; it infers no truth. + +## Strict profile over OKF + +Every concept requires: + +- explicit lifecycle, view, purpose, audience, generation, and authority; +- non-empty claim-level authoritative sources with matching footnotes; +- explicit authored relations and valid human-visible links; +- current verification for stable content; +- a current semantic quality receipt for stable content; +- human verification for normative authority; +- explicit deprecation destination or reason; +- no raw or intake reference. + +Path and view must agree: + +- product: `vision/`, `product/`, and Area `capabilities/`, `use-cases/`, + `concepts/`, or `rules/`; +- engineering: `architecture/`, `repositories/`, and Area `implementation/`; +- decision: root or Area `decisions/`; +- reference: `references/`; +- uncertainty: `uncertainties/`. + +The validator rejects code and implementation sections in product documents +and requires their stakeholder sections. It requires technical sections in +engineering documents. Deterministic checks cannot prove semantic truth, so +`verify-knowledge-quality` reads the full evidence and records a +content-hash-bound review. + +## Quality receipt + +After semantic review, record: + +```yaml +x-wf: + relations: [] + quality: + status: passed + by: workflow-agent/1 + at: 2026-07-28T12:00:00Z + content_hash: "<wfctl knowledge hash output>" + checks: + - factuality + - audience-fit + - abstraction + - completeness + - delivery-state + axes: + authority-truth: + status: passed + by: workflow-agent/1 + at: 2026-07-28T12:00:00Z + content_hash: "<same hash>" + reader-communication: + status: passed + by: workflow-agent/1 + at: 2026-07-28T12:00:00Z + content_hash: "<same hash>" +``` + +The material hash excludes `verified` and `x-wf.quality`, allowing both +receipts to bind the exact authored content without self-reference. Any other +material edit changes the hash and invalidates both receipts. The quality +receipt records a review; it creates no authority. Its two axes review +authority/truth and reader communication independently. + +## Authority by claim + +- Intent, product meaning, architecture rationale, ownership, contracts, + policy, and decisions require maintainer authority. +- Existing implementation requires pinned source code. +- Absent delivery may use a reviewed whole-scope reconstruction receipt. +- History requires pinned version-control evidence plus a reviewed archived + change or reconstruction receipt. +- External facts require primary sources. + +Pinned code resources use +`git:<repository>@<40-character-commit>#<path>[:<symbol>]`. +Reviewed changes use `project-change:<id>#<section>`. +Reviewed reconstruction decisions use +`project-reconstruction:<case-id>#<candidate-id>`. + +Authority is claim-specific. Repetition, recency, search rank, and agent +confidence do not create authority. + +## Current truth and evolution + +Keep current meaning at one stable path. A changed decision creates a successor +record and deprecates predecessors through reciprocal lineage. Do not version +whole Areas. + +The product Area index leads with current behavior. Its Evolution section +summarizes what changed, why, and what it affected. Full decision records keep +context, exact choice, rationale, alternatives, consequences, transition, open +questions, and lineage. Area logs provide local chronology without flattening +hundreds of decisions into one file. + +Canonical domain language belongs with the owning Area concept rather than a +flattened global glossary. Record the preferred term, definition, contextual +boundary, accepted aliases, and names to avoid. Proposed terminology stays in +the active change record until product authority accepts it. diff --git a/.claude/skills/explore-project-knowledge/SKILL.md b/.claude/skills/explore-project-knowledge/SKILL.md new file mode 100644 index 0000000..2ede90d --- /dev/null +++ b/.claude/skills/explore-project-knowledge/SKILL.md @@ -0,0 +1,95 @@ +--- +name: explore-project-knowledge +description: Guide a person through a project's current product knowledge without requiring them to know its Areas, capabilities, terminology, repository layout, document paths, or implementation. Use for broad newcomer questions such as what this project is, why it exists, what it can do today, what is accepted but unavailable or unknown, where to start, or what its main directions are; for follow-ups that explore one Area, capability, use case, flow, rule, delivery state, or product decision; and whenever a product manager, client, maintainer, or domain expert needs a progressive nontechnical explanation rather than knowledge authoring. Remain read-only, reveal detail gradually, and never turn a question into curation or source work without explicit need. +--- + +# Explore Project Knowledge + +Act as a product guide for someone who may not know what to ask yet. Discover +the available reading path yourself and reveal it gradually. + +Read [the exploration contract](references/exploration-contract.md) before the +first broad discovery request in a session. + +## Resolve the knowledge surface + +1. If the current repository has the `knowledge` profile, work from it. +2. If the current repository is a leaf, read `.workflow/config.json`, resolve + its configured knowledge repository, and answer from that repository. +3. Require and invoke the native QMD skill. Check `qmd status`, then search only + the `knowledge` collection. If the native skill, CLI, or project index is + unavailable, invoke `setup-workflow-environment`; do not substitute grep or + pretend discovery was complete. +4. Answer from the documents first. Do not run `wfctl check` or + `wfctl knowledge validate` merely to answer a question: exploration is the + least expensive path, and both commands are diagnostics, not reading. Run + `wfctl knowledge validate` only when a document you read looks internally + inconsistent, a link you need is broken, or the reader asks how trustworthy + the map is. When it does fail, give only the bounded trustworthy orientation + still supported by readable current documents, state that the map is + incomplete, and offer a separate repair or audit. +5. Start with `knowledge/index.md` and the reachable Area indexes. Use QMD and + `.workflow/current/knowledge-graph.json` to find candidate paths, then read + every selected Markdown document directly. The compiled graph is a + navigation cache: when a path it suggests does not exist, fall back to + direct reading rather than trusting the graph or rebuilding it mid-answer. +6. Treat curated knowledge as the answer surface, subject to its lifecycle, + provenance, verification, realization, and uncertainty. Retrieval results + and generated graphs are navigation, not evidence. + +## Choose the reader's current level + +- **Discovery:** The reader does not know the project. Explain its purpose, + intended audience, current product shape, major directions, delivery + overview, and important unknowns. +- **Area exploration:** Explain one product direction, the outcomes it owns, + major capabilities and flows, current delivery, governing rules, and useful + next branches. +- **Focused explanation:** Explain one capability, use case, flow, rule, or + product decision: current behavior first, then conditions, exceptions, + delivery, and meaningful evolution. +- **Technical or historical deep dive:** Hand routing back to + `operate-project-knowledge` so engineering realization or full decision + lineage remains a separately labeled answer. + +Do not force the reader to choose a level or use workflow terminology. Infer +the narrowest honest level from ordinary language and continue naturally. + +## Present progressive disclosure + +1. Lead with the current product answer, not file structure or methodology. +2. On discovery, present a compact project map rather than flattening every + concept. Group a large corpus into a manageable set of recognizable + directions. +3. For every item, distinguish verified/current, partial, accepted but absent, + retired, and unknown. Do not turn missing delivery into missing intent or + vice versa. Proposed plans are outside current knowledge; route an explicit + roadmap question through `operate-project-knowledge` and label it separately. +4. Use stakeholder and domain language. Explain an internal term before using + it as navigation. +5. End with three to five concrete follow-up directions derived from the + knowledge actually found. Let the reader choose what to open next. +6. Ask at most one question when a useful overview can still be given. Ask + earlier only when the request could refer to materially different projects + or product meanings. +7. Keep code, repositories, paths, schemas, APIs, workflow commands, source + metadata, and quality machinery out of the answer unless the reader asks. + +## Stay read-only + +- Do not create or edit knowledge, changes, intake, reconstruction, indexes, or + source code merely because the reader asked a question. +- Do not invoke curation or quality verification for an explanation alone. +- If current knowledge is missing, stale, contradictory, or too weak to answer, + explain the exact product-level limitation and offer the appropriate next + action: knowledge audit, source verification, reconstruction, raw intake, or + maintainer adjudication. +- Perform that action only when the user asks to continue or when their + original request explicitly required current verification or repair. +- If exploration exposes a likely documentation defect, report it separately; + do not silently repair it. + +When available, return control to `operate-project-knowledge` whenever the +request changes from understanding into auditing, verification, authoring, or +decision-making. From a leaf, follow the leaf workflow when the request becomes +implementation. diff --git a/.claude/skills/explore-project-knowledge/agents/openai.yaml b/.claude/skills/explore-project-knowledge/agents/openai.yaml new file mode 100644 index 0000000..6e08490 --- /dev/null +++ b/.claude/skills/explore-project-knowledge/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Explore Project Knowledge" + short_description: "Guide people through current project knowledge" + default_prompt: "Use $explore-project-knowledge to help me understand this project from a product perspective." diff --git a/.claude/skills/explore-project-knowledge/references/exploration-contract.md b/.claude/skills/explore-project-knowledge/references/exploration-contract.md new file mode 100644 index 0000000..e383a7a --- /dev/null +++ b/.claude/skills/explore-project-knowledge/references/exploration-contract.md @@ -0,0 +1,77 @@ +# Product exploration contract + +## Reader assumption + +Assume the reader may know only that a project exists. They are not expected to +know its taxonomy, feature names, implementation, history, or the wording used +inside the knowledge base. + +The agent owns discovery. The reader owns curiosity and product authority. + +## Response levels + +### Discovery + +Answer: + +1. What is this project for? +2. Who benefits from it? +3. What are its major product directions? +4. What works today? +5. What is partial, accepted but absent, retired, or unknown? +6. Where could the reader go next? + +Keep the first response scannable. Prefer a short orientation plus three to +five meaningful branches over an exhaustive catalog. + +### Area exploration + +Answer: + +1. What outcome does this direction own? +2. Who uses or depends on it? +3. Which major capabilities and flows belong to it? +4. Which rules and boundaries shape it? +5. What is its current delivery state? +6. Which questions or decisions remain open? + +### Focused explanation + +Lead with current behavior. Then explain rules, exceptions, delivery, examples, +and only the evolution necessary to understand the present. + +## Conversation behavior + +- A broad question receives a useful answer before any clarifying question. +- Suggested follow-ups use names and descriptions the reader has just seen. +- Each follow-up narrows one level unless the reader asks to compare multiple + directions. +- Do not expose document paths, metadata, retrieval queries, or agent workflow + in the normal answer. +- Do not offer an engineering deep dive as the only next step. Keep product + exploration useful to nontechnical readers. +- Do not create knowledge while answering. A discovered gap becomes an + explicit optional next action. +- Do not search proposals or active changes during ordinary current-product + discovery. A roadmap question is a separate route and must remain labeled as + future work. + +## Failure conditions + +Fail the exploration when it: + +- asks the reader to name an Area or capability before showing what exists; +- starts with code, architecture, repositories, or file navigation; +- presents a flat inventory with no product hierarchy; +- hides delivery or uncertainty; +- treats planned or accepted-but-absent behavior as available; +- overwhelms the first answer with every rule and historical decision; +- modifies project state without a separate request; +- invents a complete project map from sparse or unverified knowledge. + +## Evaluation discipline + +Test discovery with natural prompts that do not reveal the expected taxonomy or +rubric. Keep assertions hidden from the tested agent. Test focused conformance +separately only after the exploration itself has surfaced a real Area or +capability name. diff --git a/.claude/skills/implement-work-item/SKILL.md b/.claude/skills/implement-work-item/SKILL.md new file mode 100644 index 0000000..01b7a93 --- /dev/null +++ b/.claude/skills/implement-work-item/SKILL.md @@ -0,0 +1,104 @@ +--- +name: implement-work-item +description: Claim and implement exactly one ready issue from a central project change bundle in its exact bound leaf checkout or worktree. Use when the maintainer explicitly asks to implement a named or next frontier issue, or when resuming its existing claim. Do not use for an unresolved Wayfinder issue, unapproved shaping, lightweight unrelated edits, or work in an inferred checkout. +--- + +# Implement Work Item + +Build one bounded unit from a fresh, explicit context. The issue tracks local +progress; `change.md` remains the parent contract. + +Read [the execution contract](references/execution-contract.md) before the +first claim in a repository. + +## Load before claiming + +1. Run `wfctl work issue show <change-id> <issue-id>` from the exact intended + leaf. If no issue was named, run `wfctl work issue list <change-id>` and + choose only a frontier issue. +2. Read every listed required file completely. This includes the parent spec, + selected issue, transitive blockers, and referenced artifacts. +3. After each complete read, run `wfctl work review file <change-id> <path>`. + Do not record a receipt after reading only headings, summaries, or excerpts. +4. Run `wfctl work status <change-id>` and verify the current Git root equals + the reported code root exactly. +5. Claim before analysis or edits: + +```sh +wfctl work issue claim <change-id> <issue-id> --actor "agent:<identity>" +``` + +The claim records repository, branch, revision, and worktree identity. Stop on +any mismatch instead of choosing a sibling checkout. + +## Implement one tracer bullet + +Invoke `analyze-with-graphify` in every repository this issue legitimately +touches, then inspect the actual source. Recheck relevant curated knowledge +with `align-project-knowledge`. Search may supplement the graph; it does not +replace it. + +Implement the smallest complete behavior that satisfies this issue. Prefer a +high public seam and work one behavior cycle at a time: + +1. add one externally meaningful failing check; +2. confirm it fails for the intended reason; +3. make the minimum production change that passes it; +4. run the focused check; +5. repeat, then run broader relevant checks. + +Tests must derive expected behavior from the approved contract or an +independent authority, never from the implementation they are meant to test. +Do not over-mock the behavior under review. + +After every material maintainer turn or meaningful investigation cycle, apply +the preservation test from `manage-project-work`: if losing newly learned +information could cause repeated material investigation, a different choice, +misunderstanding, or unsafe action in a fresh session, append a complete entry +to the issue's `Discovery ledger`. Record observation, evidence, implication, +scope, and disposition without forcing it into a predefined finding category. +Update evidence and current understanding next, then refresh the issue's single +structured checkpoint last: + +```sh +wfctl work checkpoint <change-id> --issue <issue-id> \ + --actor "agent:<identity>" \ + --state "<current state>" \ + --last "<last completed action>" \ + --next "<exact next action>" +``` + +Use `--status blocked --blocker "<reason>"` when progress genuinely cannot +continue. Record deviations in the parent `change.md` when they affect approved +scope, acceptance, or decisions; refresh the parent checkpoint and reopen +framing review before continuing materially different work. + +The checkpoint may identify the latest discovery and its effect on the next +action, but the full information stays in the semantic record or a linked +artifact. Never hide a discovery only in checkpoint prose, command output, or +conversation memory. + +## Resolve honestly + +Inspect the real diff and production path. Record commands, direct source +evidence, limitations, placeholders, and unresolved risk. With normal +maintainer authorization, preserve code in the exact bound Git commit; `wfctl` +never commits automatically. + +Resolve only this issue: + +```sh +wfctl work issue complete <change-id> <issue-id> \ + --summary "<delivered outcome>" \ + --evidence "<direct inspection or command result>" +``` + +If interrupted, refresh the claimed issue checkpoint before stopping. A fresh +session begins with `wfctl work context --stage resume`, reads every required +file and discovery entry completely, and resumes the existing exact claim; it +does not infer another issue, actor, checkout, or code root. If +deliberately giving the issue back, run `wfctl work issue release`; it resets +the issue checkpoint to ready. Completion makes the issue checkpoint terminal; +then refresh the parent checkpoint with the next frontier action. Do not mark a +partial outcome completed. Final change-wide review, knowledge promotion, and +archival belong to `verify-project-work` after every required issue is terminal. diff --git a/.claude/skills/implement-work-item/agents/openai.yaml b/.claude/skills/implement-work-item/agents/openai.yaml new file mode 100644 index 0000000..cd9c2c7 --- /dev/null +++ b/.claude/skills/implement-work-item/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Implement Work Item" + short_description: "Implement one issue with durable discoveries" + default_prompt: "Use $implement-work-item to claim or resume one frontier issue in the exact bound checkout, preserve consequential discoveries in the issue, verify it, and refresh its checkpoint last." +policy: + allow_implicit_invocation: false diff --git a/.claude/skills/implement-work-item/references/execution-contract.md b/.claude/skills/implement-work-item/references/execution-contract.md new file mode 100644 index 0000000..5eea8b5 --- /dev/null +++ b/.claude/skills/implement-work-item/references/execution-contract.md @@ -0,0 +1,29 @@ +# Work-item execution contract + +## Workspace invariants + +- The central bundle is the record workspace. +- Each `Code root` from `wfctl work status` is an implementation workspace. +- A linked worktree is a distinct root even when it shares Git objects. +- Never infer a checkout from branch name, repository name, sibling paths, or + the location of `change.md`. +- Re-run status after directory changes, compaction, branch changes, and before + verification. + +## Progress invariants + +- Work exactly one claimed issue. +- Keep the issue current after requirements, evidence, scope, or the next + action changes. +- Keep parent acceptance and decisions in `change.md`; link rather than copy. +- A fresh session resumes from `wfctl work context`, the full files it lists, + and the exact claim—not from chat memory. + +## Verification invariants + +- Inspect the production path, not only tests or generated graph output. +- Prefer behavior checks at stable public seams. +- Run focused checks during development and the broader relevant suite before + resolution. +- Separate verified facts, inference, and unverified limitations. +- Passing checks do not excuse an acceptance criterion that was never traced. diff --git a/.claude/skills/manage-project-work/SKILL.md b/.claude/skills/manage-project-work/SKILL.md new file mode 100644 index 0000000..962a7cb --- /dev/null +++ b/.claude/skills/manage-project-work/SKILL.md @@ -0,0 +1,158 @@ +--- +name: manage-project-work +description: "Classify and route project work through the central knowledge-backed workflow. Use when a requested feature, fix, refactor, migration, investigation, operational change, product decision, or architecture change may be significant; when resuming active work after interruption; or when a useful lightweight result has no active or curated owner yet. This is the default project-work router: it decides between lightweight work, a pending capture, a bounded change, and deliberate Wayfinder, then maintains the owning active checkpoint." +--- + +# Manage Project Work + +Choose the least expensive path that preserves important intent, progress, and +evidence. Operate `wfctl` yourself; the maintainer supplies product authority, +corrections, and review decisions rather than managing files or commands. + +## Route the request + +Treat work as significant when it may change observable behavior, domain +meaning, a contract, state, security, reliability, operations, architecture, +ownership, or cross-repository coordination. Size alone is not decisive. + +- **Lightweight:** clearly local and behavior-preserving. Work directly. Offer + a pending capture only when a non-obvious reusable result should survive and + no active change or curated concept already owns it. +- **Bounded significant change:** the outcome can be specified honestly now. + Start one central bundle and use `specify-project-change`. +- **Wayfinder:** a consequential destination is visible, but dependent product + or architecture choices make an honest spec impossible across one session. + Recommend `shape-project-direction`; enter only after maintainer agreement. + +If classification is genuinely ambiguous, explain the material risk, recommend +one route, and ask for the maintainer's choice. Do not force full ceremony onto +trivial work or hide a significant change as lightweight. + +## Start one canonical bundle + +Create the bundle before extended significant-task discussion so the reasoning +survives compaction: + +```sh +wfctl work start <slug> --title "<title>" --mode full|slice|wayfinder +``` + +Start from one leaf for single-repository delivery. Start from knowledge with +no leaf for project-only work, or repeat `--leaf` for known multi-repository +scope. Do not bind guessed leaves merely because Wayfinder may need them later. + +Run `wfctl work status <id>` and `wfctl work context <id> --stage shape`. +Use the returned bundle in knowledge for records and only the returned code +roots for implementation. Never create a competing spec or issue tracker in a +leaf. + +## Persist material change and discovery + +A turn is material whether it comes from the maintainer or the agent. It is +material when it changes a requirement, constraint, idea, alternative, +decision, rejection, deferral, scope boundary, evidence, risk, question, next +action, or the agent's understanding of the work. + +Before continuing, apply this preservation test: + +> If this newly learned information disappeared, could a fresh session repeat +> material investigation, choose differently, misunderstand the work, or act +> unsafely? + +If yes, append a complete entry to the owning record's `Discovery ledger`. +Record the observation, evidence or missing evidence, implication, applicable +scope or lifetime, and current disposition. Do not constrain discoveries to a +fixed taxonomy. Use the claimed issue during execution, `change.md` during +shaping/direct work/final review, or a linked artifact when the supporting +material is too large; the owning ledger must link that artifact. If the +discovery changes parent scope, acceptance, or decisions, update `change.md` +as well. Preserve invalidated discoveries with a corrected disposition rather +than erasing them. + +Then update the rest of the semantic record and checkpoint: + +1. append a concise proposed, approved, rejected, deferred, or superseded + decision-ledger entry when a choice changed; +2. update affected current state, scope, acceptance, issues, decisions, + progress, evidence, and discovery implications; +3. preserve rationale without copying the chat transcript or turning the + discovery ledger into an activity log; +4. run `wfctl work checkpoint <id>` for bundle-level work, or add + `--issue <issue-id>` for a claimed issue. Supply current state, last + completed action, exact next action, blockers, and actor. + +The checkpoint may mention a discovery ID and its effect on the frontier, but +must not duplicate the discovery. If useful material has no active or curated +owner, follow the pending-capture route instead. + +New bundle schemas require the ledger section. When an entry exists, workflow +context validates its stable `DISC-*` ID and non-empty observation, evidence, +implication, scope, and disposition. Fix malformed entries before continuing; +do not satisfy the gate with placeholders. + +The checkpoint hash binds the record after those edits. Never edit its YAML by +hand. If any owned record changes afterward, `wfctl work context` reports the +checkpoint stale and the agent must refresh it before claiming or closing work. + +After compaction, interruption, or a clean-session start, first run `wfctl work +context --stage resume` without an ID. It auto-selects only when exactly one +active record is bound to the current checkout. If none exists, do not invent +one. If several exist, run `wfctl work status`, present their human outcomes, +and ask the maintainer which one to resume; never guess from recency, branch, or +directory name. + +For the selected record, inspect status and the reported checkpoints, then +read every required file completely, including the entire discovery ledger, +before acting. Continue only in the exact reported code roots and existing +claim. If a binding or checkpoint is invalid, stop and reconcile it rather +than reconstructing state from chat memory. A checkpoint locates the frontier; +it never replaces the required full reads. + +If an upgraded legacy bundle has no structured checkpoint, read its current +record and former Progress/Handoff sections completely, then run `wfctl work +checkpoint` once to adopt the new model. Preserve the old prose as lineage, but +do not maintain a second resume state afterward. + +If a pre-ledger bundle has no `Discovery ledger`, do not fabricate past +discoveries. Add the section when material work next changes that owner and +preserve new discoveries from that point forward; old bundle versions remain +readable for compatibility. + +## Route the active bundle + +- Use `shape-project-direction` only for deliberate Wayfinder. +- Use `specify-project-change` to synthesize bounded work or collapse a clear + map into stable acceptance criteria. +- Use `split-project-change` when approved work needs several dependency-aware + sessions. +- Use `implement-work-item` for exactly one frontier issue. A small bounded + change may be implemented directly from `change.md` after framing approval, + while preserving the same workspace and progress rules. +- Use `verify-project-work` for complete file accounting, spec/implementation + reconciliation, knowledge promotion, completion review, and archival. + +Framing and completion decisions are recorded with `wfctl work approve <id> +--stage framing|completion --by human:<maintainer-id>`, never by editing +`maintainer_review` directly. The command requires an interactive terminal or +an out-of-band `--token`; a hand-written receipt fails the completion gate. + +`changes/active/<id>/` is already the knowledge-side living record. There is +no final dump into `raw/`. Completed closure moves the entire bundle intact to +`changes/archive/<id>/`; verified durable truth is separately curated into +`knowledge/`. + +## Pending capture + +Do not create a capture when an active change, issue, intake case, +reconstruction, or curated concept already owns the material; update that owner +and its checkpoint instead. When useful lightweight material genuinely has no +owner and the maintainer accepts retaining it, run: + +```sh +wfctl work capture add <slug> --title "<fact to retain>" +``` + +Complete the returned pending capture. It remains non-authoritative until the +knowledge agent routes it to a real destination or discards it through +`wfctl work capture resolve`. Never copy active progress into `changes/inbox/` +and never cite raw or intake material as current truth. diff --git a/.claude/skills/manage-project-work/agents/openai.yaml b/.claude/skills/manage-project-work/agents/openai.yaml new file mode 100644 index 0000000..b83903f --- /dev/null +++ b/.claude/skills/manage-project-work/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Manage Project Work" + short_description: "Route work and preserve resumable context" + default_prompt: "Use $manage-project-work to classify or resume this request, preserve consequential discoveries in their semantic owner, and refresh the owning checkpoint last." diff --git a/.claude/skills/manage-project-work/assets/bundle-review.md b/.claude/skills/manage-project-work/assets/bundle-review.md new file mode 100644 index 0000000..bd0545e --- /dev/null +++ b/.claude/skills/manage-project-work/assets/bundle-review.md @@ -0,0 +1,11 @@ +--- +workflow_version: 1 +kind: bundle-review +updated_at: "{{UPDATED_AT}}" +files: [] +--- + +# Bundle review + +This ledger is maintained by `wfctl`. Each receipt accounts for one complete +bundle file at an exact content hash. It is not proof of semantic correctness. diff --git a/.claude/skills/manage-project-work/assets/capture.md b/.claude/skills/manage-project-work/assets/capture.md new file mode 100644 index 0000000..560022d --- /dev/null +++ b/.claude/skills/manage-project-work/assets/capture.md @@ -0,0 +1,33 @@ +--- +capture_version: 1 +kind: capture +id: "{{CAPTURE_ID}}" +title: "{{TITLE}}" +status: pending +created_at: "{{CREATED_AT}}" +source: {} +claim_refs: [] +resolution: null +--- + +# Summary + +Record the reusable result, proposal, limitation, or observation. Separate +verified facts, maintainer intent, and inference. + +# Evidence + +Record exact source paths, revisions, commands, outputs, and known limits. For +intake or reconstruction material, retain fully qualified claim references in +`claim_refs`; never cite raw files as authority. + +# Why retained + +Explain why this material may affect a future product or engineering decision +despite not belonging to active work or curated knowledge yet. + +# Suggested route + +Recommend one next route: discard it, curate verified truth, or start/link a +normal significant change. The capture remains non-authoritative while it is +pending in `changes/inbox/`. diff --git a/.claude/skills/manage-project-work/assets/wayfinder-map.md b/.claude/skills/manage-project-work/assets/wayfinder-map.md new file mode 100644 index 0000000..61f9844 --- /dev/null +++ b/.claude/skills/manage-project-work/assets/wayfinder-map.md @@ -0,0 +1,36 @@ +--- +workflow_version: 1 +kind: wayfinder-map +status: charting +destination: "" +notes: [] +resolved: [] +fog: [] +out_of_scope: [] +created_at: "{{CREATED_AT}}" +updated_at: "{{CREATED_AT}}" +--- + +# Destination + +Name what reaching the end of this map makes possible. This is a planning +destination, not an instruction to implement it during Wayfinder. + +# Standing notes + +Record domain, constraints, accepted vocabulary, and skills or sources every +session must consult. + +# Resolved route + +`wfctl` maintains a one-line named pointer for each completed Wayfinder issue. +The issue remains the only home of its full resolution. + +# Not yet specified + +Keep in-scope fog that cannot yet be phrased as a precise question. Remove a +fog item when it becomes an issue or is ruled out of scope. + +# Out of scope + +Record boundaries outside this destination and the reason for each boundary. diff --git a/.claude/skills/manage-project-work/assets/work-issue.md b/.claude/skills/manage-project-work/assets/work-issue.md new file mode 100644 index 0000000..df4086f --- /dev/null +++ b/.claude/skills/manage-project-work/assets/work-issue.md @@ -0,0 +1,70 @@ +--- +workflow_version: 3 +kind: work-issue +id: "{{ISSUE_ID}}" +title: "{{TITLE}}" +phase: delivery +type: delivery +status: draft +blocked_by: [] +satisfies: [] +repositories: [] +artifacts: [] +claim: null +resolution: null +created_at: "{{CREATED_AT}}" +updated_at: "{{CREATED_AT}}" +checkpoint_version: 1 +checkpoint: + status: ready + stage: implement + actor: system:wfctl + current_state: Issue is ready but unclaimed. + last_completed: Issue record created. + next_action: Read the required context and claim the issue. + blockers: [] + updated_at: "{{CREATED_AT}}" + basis_sha256: "{{CHECKPOINT_BASIS}}" +--- + +# Outcome + +State the complete behavior, decision, or fact this issue must deliver. Keep it +small enough for one fresh agent session when possible. + +# Acceptance contribution + +Explain how this issue contributes to its `satisfies` acceptance IDs. For a +Wayfinder issue, state the precise question it resolves instead. + +# Constraints and boundaries + +Record relevant project knowledge, approved decisions, repository scope, and +explicit exclusions. Do not copy the parent specification. + +# Discovery ledger + +Preserve newly learned information when losing it could make a future session +repeat material investigation, choose differently, misunderstand the work, or +act unsafely. Do not use this as a chronological activity log or restrict it to +a predefined class of findings. + +Append one durable block per discovery, replacing the placeholders: + +```markdown +## DISC-NNN — Concise title + +- **Observation:** What was learned and its uncertainty. +- **Evidence:** Direct basis or missing evidence. +- **Implication:** What this changes. +- **Scope:** Where and for how long it applies. +- **Disposition:** Its current owner or next destination in plain language. +``` + +Preserve an invalidated entry and update its disposition rather than deleting +it. + +# Verification + +Record direct source inspection, executable checks, outcomes, and limitations. +Graph or search output is navigation evidence, not implementation proof. diff --git a/.claude/skills/manage-project-work/assets/work-spec.md b/.claude/skills/manage-project-work/assets/work-spec.md new file mode 100644 index 0000000..7cb17e3 --- /dev/null +++ b/.claude/skills/manage-project-work/assets/work-spec.md @@ -0,0 +1,177 @@ +--- +workflow_version: 5 +id: "{{WORK_ID}}" +title: "{{TITLE}}" +mode: "{{MODE}}" +status: shaping +scope: leaf +created_at: "{{CREATED_AT}}" +updated_at: "{{CREATED_AT}}" +checkpoint_version: 1 +checkpoint: + status: active + stage: shape + actor: system:wfctl + current_state: Initial framing is pending. + last_completed: Central work bundle created. + next_action: Persist the first agreed framing and refresh this checkpoint. + blockers: [] + updated_at: "{{CREATED_AT}}" + basis_sha256: "{{CHECKPOINT_BASIS}}" +repositories: [] +acceptance: [] +direction: + status: bounded + map: "" + resolved_at: "" +knowledge_alignment: + reviewed: [] + conflicts: [] +graph_evidence: + queries: [] +knowledge_promotion: + status: pending + concepts: [] + reason: "" +maintainer_review: + framing: + status: pending + by: "" + at: "" + notes: [] + completion: + status: pending + by: "" + at: "" + notes: [] +verification: + result: pending + revision: "" + worktree_id: "" + repositories: [] + acceptance: [] + acceptance_reviewed: false + implementation_reviewed: false + knowledge_reviewed: false + checks: [] + unresolved: [] +--- + +# Summary + +State the intended outcome and why it matters. + +# Current state + +Maintain the latest agreed problem, desired outcome, constraints, scope, +assumptions, and risks. Rewrite this section whenever the current understanding +changes; keep resumable execution state only in the structured checkpoint. + +# Direction map + +For a broad initiative, record the destination, current landscape, affected +Areas and actors, constraints, success signals, and explicit non-goals. For a +bounded task, state `Not required — bounded change` with a short reason. + +# Domain language + +Record proposed canonical terms, definitions, accepted aliases, names to +avoid, and their resolution state. Keep unresolved vocabulary here; promote it +to current knowledge only after approval. + +# Decision frontier + +List only unresolved choices whose answers can materially change product +meaning, scope, architecture, ownership, or the next safe action. Rank them by +leverage. For a bounded task with no frontier, state `None`. + +# Uncertainty and fog + +Record unknown facts, missing authority, contradictions, dependencies, and +risks. For each item, state its impact and what would resolve it. + +# Open questions + +- Record unresolved questions that can change the solution, authority, or scope. + +# Discussion and decision ledger + +Append one concise entry after every material maintainer turn. Preserve rejected, +deferred, and superseded directions instead of rewriting history. + +| At | Status | Subject | Outcome and rationale | +| --- | --- | --- | --- | +| {{CREATED_AT}} | proposed | Initial framing | Awaiting discussion. | + +# Current behavior and evidence + +Record Graphify queries, the source locations they led to, and direct inspection +of the actual code. Graph output is navigation evidence, never the authority for +an implementation claim. Add supplementary text-search findings only after +graph analysis. + +# Discovery ledger + +Preserve newly learned information when losing it could make a future session +repeat material investigation, choose differently, misunderstand the work, or +act unsafely. This is an information-acquisition ledger, not an activity log and +not a closed list of "findings" categories. + +Append one durable block per discovery, replacing the placeholders: + +```markdown +## DISC-NNN — Concise title + +- **Observation:** What was learned, including uncertainty when unverified. +- **Evidence:** Direct basis, or the evidence that is still missing. +- **Implication:** What this changes for understanding or action. +- **Scope:** Where it applies and any lifetime or invalidation condition. +- **Disposition:** What now owns it or must happen next, in plain language. +``` + +Preserve superseded or disproven entries and update their disposition instead +of deleting the path by which the team learned. + +# Knowledge alignment + +List relevant vision, product, architecture, decision, repository, and uncertainty concepts. Record conflicts and maintainer resolutions. + +# Scope + +## In + +- Define included behavior. + +## Out + +- Define explicit exclusions. + +# Decisions + +- Record the current approved decisions with enough rationale to guide + implementation. Link each entry to its ledger history when it evolved. + +# Plan and progress + +- [ ] Add concrete implementation and validation steps. + +# Acceptance criteria + +- [ ] Add observable, testable completion criteria. + +# Verification evidence + +Record fresh commands, results, directly inspected code at the bound revision, +and criterion-by-criterion inspection. A passing test suite or Graphify result +alone is not complete evidence. + +# Knowledge promotion + +List the curated concepts updated by this change, or explain why the completed +change does not alter durable project intent, meaning, decisions, contracts, +boundaries, ownership, or operational knowledge. Promote only claims verified +against authoritative sources; untrusted raw intake is never provenance. + +# Deviations and unresolved work + +State deviations, remaining risks, placeholders, mocks, follow-ups, or `None`. diff --git a/.claude/skills/qmd/SKILL.md b/.claude/skills/qmd/SKILL.md new file mode 100644 index 0000000..0d4b048 --- /dev/null +++ b/.claude/skills/qmd/SKILL.md @@ -0,0 +1,295 @@ +--- +name: qmd +description: Search local markdown knowledge bases, notes, docs, and wikis with QMD. Use when users ask to find notes, retrieve documents, inspect a wiki, answer from indexed markdown, or set up QMD access. +license: MIT +compatibility: Requires qmd CLI or MCP server. Install via `npm install -g @tobilu/qmd`. +metadata: + author: tobi + version: "2.2.0" +allowed-tools: Bash(qmd:*), mcp__qmd__* +--- + +# QMD - Query Markdown Documents + +## How search works + +QMD searches local markdown collections: notes, docs, wikis, transcripts, and +project knowledge bases. Use it before web search when the answer may already be +in indexed local files. + +The workflow is always: + +1. Search for candidate documents. +2. Retrieve the full source with `qmd get` or `qmd multi-get`. +3. Answer from retrieved text, citing paths or docids. + +Do not answer from snippets alone when the user needs facts, decisions, quotes, +or nuance. Snippets are only leads. + +Typical loop: + +```bash +qmd search "merchant reality support interviews" -n 5 +# leads: #abc123 concepts/customer-proximity.md; #def432 sources/merchant-call.md +qmd multi-get "#abc123,#def432" --format md +``` + +**Default to structured `qmd query` with `intent:`, `lex:`, `vec:`, and `hyde:` +fields that you write yourself.** You are a better query expander than the +built-in model: you know the user's actual goal, the domain vocabulary, and the +nearby-but-wrong concepts to avoid. Do not just paste the user's words into +`qmd query "..."` and hope the expansion model guesses right — supply the +`intent:` and craft the lexical and semantic terms deliberately (see +[Pick the right search mode](#pick-the-right-search-mode)). + +When reporting what you retrieved, a compact note is enough; do not paste whole +files unless needed: + +```text +Retrieved: +- #abc123 concepts/customer-proximity.md +- #def432 sources/merchant-call.md +``` + +## Pick the right search mode + +Use **BM25 lexical search** when you know exact words, titles, names, code +symbols, or rare phrases: + +```bash +qmd search "cockpit OKR Goodhart" -n 10 +qmd search '"AI Before Headcount"' -c concepts -n 5 +``` + +Use **`qmd query` with structured fields** when the user describes an idea +indirectly, uses different wording than the source, or needs conceptual recall. +**This is the default mode — write the fields yourself rather than leaning on +query expansion.** Combine exact anchors with semantic recall: + +```bash +qmd query $'intent: Find the concept note about metrics as instruments without letting OKRs replace judgment.\nlex: cockpit instruments OKR Goodhart metrics judgment\nvec: data informed not metric driven product judgment\nhyde: A concept note says metrics are useful like cockpit instruments, but leaders should remain data-informed rather than metric-driven because OKRs and dashboards can Goodhart product judgment.' +``` + +Structured query fields (you author each one — do not delegate this to the +expansion model): + +- `intent:` states what you are trying to find **and what to avoid**. Always + supply this. It steers ranking away from nearby-but-wrong concepts. +- `lex:` exact terms, aliases, titles, code symbols, and rare words you expect + in the source. This is your own keyword expansion. +- `vec:` paraphrases the idea in natural language, in source-like wording. +- `hyde:` describes the document or answer that would satisfy the request. + +You do not need all four every time, but you should almost always write at least +`intent:` plus one of `lex:`/`vec:`. A bare `qmd query "the user's sentence"` +throws away the context only you have and relies on the built-in expander to +reconstruct it — prefer the structured form. + +If you genuinely have nothing to expand (a single rare token, a verbatim phrase), +that is a job for `qmd search`, not bare `qmd query`: + +```bash +qmd query --format json --explain $'intent: ...\nlex: ...\nvec: ...' # inspect ranking +``` + +If `qmd query` is slow or model/GPU setup fails, fall back to `qmd search` with +better lexical terms. + +## Retrieve sources + +Search results include docids like `#abc123` and `qmd://...` paths. Fetch them: + +```bash +qmd get "#abc123" +qmd get qmd://concepts/ai-before-headcount.md +qmd multi-get "#abc123,#def432" --format md +qmd multi-get 'concepts/{ai-before-headcount.md,data-informed-not-metric-driven.md}' --format md +qmd multi-get 'sources/podcast-2025-*.md' -l 80 +``` + +Use `multi-get` when comparing several hits or gathering context across pages. + +### Output is line-numbered and carries the docid — cite both + +`get` and `multi-get` are **line-numbered by default** and always print the +document's `#docid` and `qmd://` path. So `get` output looks like: + +```text +qmd://concepts/note.md #abc123 +--- + +1: # Metrics as instruments +2: +3: Treat dashboards like cockpit instruments... +``` + +Cite the docid and exact line numbers in your answer, and use the numbers to ask +for the next slice. Pass `--no-line-numbers` only when you need raw content to +copy verbatim (e.g. reproducing a code block). + +When you need to open or edit the underlying file (e.g. hand a path to `Read`, +`Edit`, or an editor), add `--full-path`. It replaces the `qmd://` URL + docid +header with the document's on-disk path, falling back to the canonical header if +the file no longer exists on disk: + +```text +$ qmd get "#abc123" --full-path +/Users/you/notes/concepts/note.md +--- + +1: # Metrics as instruments +``` + +`--full-path` works the same way on `qmd search` and `qmd query`: result paths +become the file's on-disk path — `./`-prefixed relative path when the file is +inside `$PWD`, absolute realpath otherwise — and the per-result `#docid` is +dropped because the path is the identifier. The leading `./` is intentional so +the output is unambiguously a filesystem path and cannot be mistaken for a bare +collection-relative string. Default search/query output still uses `qmd://` +URIs; only opt into `--full-path` when you specifically need a path you can hand +to a non-QMD tool. + +### Read line ranges with the `:from:count` suffix — never pipe through `sed`/`head`/`tail` + +`qmd get` slices files itself. Use the suffix or flags; do **not** shell out to +`sed -n`, `head`, `tail`, or `awk` to pull a line range. Piping defeats docid +resolution, virtual-path lookups, line numbering, and the header, and it is +slower and more error-prone. + +The most compact form is a `:from:count` suffix right on the path or docid — +prefer it: + +```bash +qmd get "#abc123:120:40" # 40 lines starting at line 120 +qmd get qmd://concepts/note.md:200:60 # lines 200–259 +qmd get "#abc123:120" # from line 120 to end of file +qmd get "#abc123" --from 120 -l 40 # equivalent, using flags +``` + +Suffix and flags: + +- `<path>:<from>:<count>` — start at line `<from>`, read `<count>` lines. **Best + for reading around a search hit.** +- `<path>:<from>` — start at `<from>`, read to end of file. +- `--from <line>` / `-l <lines>` — flag equivalents. Explicit flags override the + suffix, so `... :5:2 -l 1` reads 1 line. +- `--no-line-numbers` — drop the `N:` prefixes (line numbers are on by default). + +Wrong: `qmd get "#abc123" | sed -n '120,160p'` +Right: `qmd get "#abc123:120:40"` + +Search results include a `:line` anchor on each hit — feed it straight into +`qmd get path:line:<n>` to read a window around the match (line numbers in the +output will start at `line`). + +## Discover what is indexed + +```bash +qmd collection list +qmd ls +qmd status +``` + +Add collection filters when broad searches drift into the wrong corpus: + +```bash +qmd search "headcount autonomous agents" -c concepts -n 10 +qmd query "merchant support product reality" -c concepts -c sources -n 10 +``` + +Omit `-c` to search everything. + +## MCP Tool: `query` + +When using the MCP server, prefer structured searches: + +```json +{ + "searches": [ + { "type": "lex", "query": "cockpit OKR Goodhart" }, + { "type": "vec", "query": "data informed not metric driven product judgment" }, + { "type": "hyde", "query": "A concept note explains that metrics are useful as instruments, but leaders should not let OKRs or dashboards replace judgment." } + ], + "intent": "Find the concept note about using metrics as instruments without becoming metric-driven.", + "collections": ["concepts"], + "limit": 10 +} +``` + +Query types: + +- `lex` — BM25 keyword search. Best for exact terms, names, titles, and code. +- `vec` — vector semantic search. Best for natural-language concepts. +- `hyde` — vector search using a hypothetical answer/document passage. + +## Query craft + +Good QMD searches mix three things: + +1. **Title/alias anchors:** exact page titles, named entities, phrases. +2. **Semantic paraphrase:** how a human would describe the idea. +3. **Negative space:** enough intent to avoid nearby-but-wrong concepts. + +Examples: + +```bash +# Exact-ish title lookup +qmd search '"arm the rebels" merchants tools big companies' -c concepts + +# Semantic concept lookup +qmd query $'intent: Find the customer proximity concept, not generic customer delight.\nlex: support pseudonymous merchant customer interviews\nvec: founder stays close to merchant reality through support and product use' + +# Source lookup +qmd search "six-week cadence WhatsApp merchant relationships Shawn Ryan" -c sources -n 10 +``` + +## Setup and maintenance + +Only mutate indexes when the user asked for setup or maintenance. Searching and +retrieving are safe; collection/index mutation is not a casual first step. + +```bash +npm install -g @tobilu/qmd +qmd collection add ~/notes --name notes +qmd update +qmd embed +``` + +Health and diagnostics: + +```bash +qmd doctor +qmd status +qmd pull +``` + +`qmd doctor` checks config, model cache, device/GPU setup, vector fingerprints, +and common environment overrides. If a model-backed command fails, run it before +changing configuration. + +## MCP setup + +See `references/mcp-setup.md` for Claude Code, Claude Desktop, OpenClaw, and HTTP +server configuration. + +## Pitfalls + +- **Do not stop at snippets.** Fetch documents before making claims. +- **Do not slice files with `sed`/`head`/`tail`.** Use the `path:from:count` + suffix (e.g. `qmd get "#abc123:120:40"`) or `--from`/`-l`. Output is already + line-numbered; piping breaks docid resolution, the header, and virtual paths. +- **Do not lean on query expansion.** Write `intent:`/`lex:`/`vec:`/`hyde:` + yourself. A bare `qmd query "user sentence"` discards the context only you + have. You expand the query; the model just ranks. +- **Do not overuse semantic search.** If you know exact titles or terms, BM25 is + faster and often better. +- **Do not mutate indexes casually.** `qmd collection add`, `qmd update`, and + `qmd embed` change local state and can be expensive. +- **Model-backed commands can be environment-sensitive.** If `qmd query`, + `qmd vsearch`, or reranking fails because local models/GPU are unavailable, + use `qmd search` and stronger lexical/structured terms. +- **Ambiguous user wording needs intent.** Add `intent:` rather than hoping query + expansion guesses the right domain. +- **Collection names matter.** Search `concepts` for synthesized wiki pages, + `sources` for transcripts/raw source pages, and docs collections for code or + project documentation. diff --git a/.claude/skills/qmd/references/mcp-setup.md b/.claude/skills/qmd/references/mcp-setup.md new file mode 100644 index 0000000..5d32a62 --- /dev/null +++ b/.claude/skills/qmd/references/mcp-setup.md @@ -0,0 +1,102 @@ +# QMD MCP Server Setup + +## Install + +```bash +npm install -g @tobilu/qmd +qmd collection add ~/path/to/markdown --name myknowledge +qmd embed +``` + +## Configure MCP Client + +**Claude Code** (`~/.claude/settings.json`): +```json +{ + "mcpServers": { + "qmd": { "command": "qmd", "args": ["mcp"] } + } +} +``` + +**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`): +```json +{ + "mcpServers": { + "qmd": { "command": "qmd", "args": ["mcp"] } + } +} +``` + +**OpenClaw** (`~/.openclaw/openclaw.json`): +```json +{ + "mcp": { + "servers": { + "qmd": { "command": "qmd", "args": ["mcp"] } + } + } +} +``` + +## HTTP Mode + +```bash +qmd mcp --http # Port 8181 +qmd mcp --http --daemon # Background +qmd mcp stop # Stop daemon +``` + +## Tools + +### structured_search + +Search with pre-expanded queries. + +```json +{ + "searches": [ + { "type": "lex", "query": "keyword phrases" }, + { "type": "vec", "query": "natural language question" }, + { "type": "hyde", "query": "hypothetical answer passage..." } + ], + "limit": 10, + "collection": "optional", + "minScore": 0.0 +} +``` + +| Type | Method | Input | +|------|--------|-------| +| `lex` | BM25 | Keywords (2-5 terms) | +| `vec` | Vector | Question | +| `hyde` | Vector | Answer passage (50-100 words) | + +### get + +Retrieve document by path or `#docid`. + +| Param | Type | Description | +|-------|------|-------------| +| `path` | string | File path or `#docid` | +| `full` | bool? | Return full content | +| `lineNumbers` | bool? | Add line numbers | + +### multi_get + +Retrieve multiple documents. + +| Param | Type | Description | +|-------|------|-------------| +| `pattern` | string | Glob or comma-separated list | +| `maxBytes` | number? | Skip large files (default 10KB) | + +### status + +Index health and collections. No params. + +## Troubleshooting + +- **Not starting**: `which qmd`, `qmd mcp` manually +- **No results**: `qmd collection list`, `qmd embed` +- **Slow first search**: Normal, models loading (~3GB) diff --git a/.claude/skills/setup-workflow-environment/SKILL.md b/.claude/skills/setup-workflow-environment/SKILL.md new file mode 100644 index 0000000..fc6d370 --- /dev/null +++ b/.claude/skills/setup-workflow-environment/SKILL.md @@ -0,0 +1,132 @@ +--- +name: setup-workflow-environment +description: Install, update, repair, or explain the shared project workflow environment in a clean or initialized knowledge repository or leaf source repository. Use when a maintainer asks to bootstrap wfctl, connect a repository to project knowledge, update workflow rules or skills, preserve existing AGENTS.md or CLAUDE.md instructions, or diagnose a broken workflow installation. +--- + +# Setup Workflow Environment + +Install through deterministic `wfctl` operations while preserving all unowned consumer content. + +## Command ownership + +Run `wfctl init`, `wfctl upgrade`, and `wfctl check` yourself when terminal +access permits. Ask the maintainer for repository kind, paths, skill targets, +scope, and conflict decisions, not routine command execution. Provide a manual +command only when bootstrapping without `wfctl`, missing authority, or tool +access prevents execution; state that blocker explicitly. + +Treat `wfctl init knowledge`, `wfctl init leaf`, and repository-local `wfctl +upgrade` as the normal optional maintainer-facing CLI entry points, and all may +be delegated to this skill. `--target`, `check`, `knowledge`, `work`, QMD, and +Graphify operations belong to the agent unless the maintainer explicitly +requests manual or automation-oriented instructions. + +## Procedure + +1. Confirm that Bun and `wfctl` are available. If `wfctl` is missing, stop and + tell the maintainer how to install or link the canonical Bun package. +2. Confirm `qmd --version` reports at least `2.5.3`. If it is missing or old, + ask for installation authority and run + `bun install -g @tobilu/qmd@2.5.3`. QMD is the supported knowledge + retrieval engine; do not substitute a custom indexer. +3. Before leaf initialization, require both the `graphify` CLI and the official + native Graphify skill in the current session. Do not require either for a + knowledge repository that is not inspecting source. If the CLI is absent, + ask for user-level installation authority and run `uv tool install + graphifyy`. If the native skill is absent, ask for authority and run + `graphify install --platform <agent>` once for every selected agent + platform. Then tell the maintainer to restart the agent and stop: an on-disk + skill is not active in the current session. After restart, verify both + requirements and repeat `wfctl init leaf`. Knowledge retrieval and raw + intake use QMD, not Graphify. +4. Identify whether the target is a `knowledge` or `leaf` repository. + A knowledge target may be a new directory without Git: ask the maintainer + for authority to initialize it and pass `--init-git`. In an interactive + terminal, `wfctl` asks this itself. A leaf must already be an existing Git + repository; never use `--init-git` to turn an arbitrary leaf path into one. +5. For a leaf repository, obtain the local knowledge-repository path. Do not guess it. +6. Inspect existing `AGENTS.md`, `CLAUDE.md`, `.claude/rules`, `.agents/skills`, and `.claude/skills`, including symlink targets. +7. Run `wfctl init <knowledge|leaf> --target <path>` with `--knowledge <path>` + for a leaf. Use the default project skill scope unless the maintainer chooses + user scope or no skill installation. The dependency preflight must pass + before the command writes files. A successful leaf initialization must also + run `graphify update .` from the exact target checkout; do not accept a + checkout whose local graph was never built. Preserve the existing root + `.gitignore` while ensuring it excludes `graphify-out/`. Successful leaf + init must also register durable repository identity in knowledge and add + this exact worktree to ignored local state. It must not change that + repository's active reconstruction selection. +8. Review the preview before confirming. For each conflict, preserve the + existing content, accept the offered per-file backup and replacement, or + stop. Never invent a blanket overwrite. +9. Let `wfctl` update managed instruction and guide blocks. If their markers + are malformed or duplicated, stop and repair them with the maintainer rather + than replacing the whole file. Use `wfctl init <kind> + --print-instructions agents|guide` to obtain the exact managed text. +10. Confirm that `PROJECT_WORKFLOW.md` preserves pre-existing text outside the + managed block. +11. Confirm the installed skills include the version-matched official `qmd` + skill from `qmd skills path qmd` for every selected agent. New skills are + not active in the current session automatically; tell the maintainer to + restart the agent session before knowledge-dependent work. + Confirm `explore-project-knowledge` is installed for both profiles so + project discovery works identically from knowledge and leaf sessions. + Confirm `shape-project-direction`, `specify-project-change`, + `split-project-change`, and `implement-work-item` are installed for both + profiles. For a knowledge profile, also confirm + `research-project-context` is installed. +12. For a knowledge profile, confirm that `.qmd/index.yml` defines separate + `knowledge`, `changes`, `intake`, `reconstruction`, and `raw` collections. + Confirm that `reconstruction/active` and `reconstruction/archive` exist and + `reconstruct-project-knowledge` is installed. `wfctl init` + builds `.workflow/current/knowledge-graph.json` plus + `.workflow/current/claim-ledger.json` and runs `qmd update`, so explicit + knowledge navigation, claim-lineage audit, and BM25 retrieval must be + ready immediately. Treat + `qmd-models` and `qmd-embeddings` warnings as optional semantic setup, not + as lexical-index success. Ask before `qmd pull` or `qmd embed`; the current + model set is roughly 2 GB. +13. Run `wfctl check --target <path>` and report every failure and warning. For + a leaf, confirm `graphify-graph` passes and refers to this checkout, not a + sibling repository or another worktree, and confirm `graphify-ignore` + passes. Confirm `repository-connection` identifies this exact known + checkout and reports whether it is selected as the default, awaiting + selection, or registered as an alternative for reconstruction. + For a knowledge repository, report registered repositories, known + worktrees, and explicit default reconstruction selections. Deferred + selection is healthy during setup and must not be reported as a warning. + Do not select during + initialization; `reconstruct-project-knowledge` owns contextual selection + when reconstruction is actually requested. +14. For a knowledge profile, run `wfctl knowledge validate` and + `wfctl knowledge build`; do not create a raw intake case unless intake + processing was requested. +15. Point the maintainer to `PROJECT_WORKFLOW.md`; it defines their review + responsibilities for both profiles. + +Use `wfctl upgrade` for an existing installation. A workflow 0.3 knowledge +repository requires a content migration after the generated assets upgrade: + +1. inventory every existing curated concept without changing its meaning; +2. route stakeholder current behavior to `curate-product-knowledge`; +3. route technical realization to `curate-engineering-knowledge`; +4. split any mixed concept and connect the two views with explicit links; +5. preserve decision lineage, provenance, delivery state, and uncertainty; +6. invoke `verify-knowledge-quality` and record fresh quality plus normal + verification receipts for the final content hash; +7. run `wfctl knowledge validate` and `wfctl knowledge build`. + +Never infer a view, audience, or product meaning merely to silence an upgrade +error. Leave the concept draft and ask the maintainer when authority is +missing. Read [the installation contract](references/install-contract.md) when +handling unusual files or symlinks. + +## Safety + +- Preserve text outside `wfctl` markers. +- Never replace an existing file or directory with a symlink. +- Treat local edits to owned generated files as conflicts. +- Do not claim Codex consumes `.claude/rules`; Codex receives the routing contract through `AGENTS.md`. +- Let the pinned `skills` CLI install both workflow skills and QMD's + version-matched native skill for the selected agent targets and scope. Do not + hand-copy skills around it. diff --git a/.claude/skills/setup-workflow-environment/agents/openai.yaml b/.claude/skills/setup-workflow-environment/agents/openai.yaml new file mode 100644 index 0000000..e922f25 --- /dev/null +++ b/.claude/skills/setup-workflow-environment/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Setup Workflow Environment" + short_description: "Install or repair wfctl environments" + default_prompt: "Use $setup-workflow-environment to install or repair this repository workflow safely." diff --git a/.claude/skills/setup-workflow-environment/references/install-contract.md b/.claude/skills/setup-workflow-environment/references/install-contract.md new file mode 100644 index 0000000..9a087bb --- /dev/null +++ b/.claude/skills/setup-workflow-environment/references/install-contract.md @@ -0,0 +1,140 @@ +# Installation contract + +## Instruction files + +- Create `AGENTS.md` when absent. +- Update only the block between `<!-- wfctl:begin -->` and `<!-- wfctl:end -->`. +- When `CLAUDE.md` is absent, link it to `AGENTS.md`. +- When `CLAUDE.md` is a regular file, preserve it and maintain a separate marked block. +- Treat malformed, duplicated, broken, or externally targeted managed symlinks as conflicts. + +## Skills + +- Delegate skill placement to the pinned `skills` CLI. +- Use the installer's copy mode for every selected agent. Do not create + cross-agent symlinks between `.agents/skills` and `.claude/skills`. +- Default to project scope; use user scope or no installation only when the + maintainer chooses it. +- Install `setup-workflow-environment`, `analyze-with-graphify`, and the + version-matched official `qmd` skill for both profiles. +- Install `operate-project-knowledge` only for the knowledge profile as the + default router for explanation, history, audit, navigation, contradiction, + and triage requests. +- Install `explore-project-knowledge` for both profiles. It gives knowledge and + leaf sessions the same read-only progressive product-discovery path through + the configured knowledge repository without requiring user knowledge of + Areas, capabilities, or files. +- Install `process-raw-intake` only for the knowledge profile. +- Install `reconstruct-project-knowledge` only for the knowledge profile. +- Install `research-project-context` only for the knowledge profile. +- Install `shape-project-direction` for both profiles so an unbounded + significant initiative can resolve its Wayfinder map inside the same central + bundle before implementation starts. +- Install `specify-project-change`, `split-project-change`, and + `implement-work-item` for both profiles so specification synthesis, + dependency-aware issue creation, and exact-worktree execution are explicit + reusable modes rather than hidden conventions. +- Install `curate-project-knowledge` for both profiles because a leaf agent + must promote durable truth before closing significant work. +- Install `curate-product-knowledge`, `curate-engineering-knowledge`, and + `verify-knowledge-quality` for both profiles. The orchestrator depends on + them to keep stakeholder and technical views separate and to bind independent + authority/truth and reader-communication review to the current content hash. +- Install alignment, work management, and verification skills for both + profiles. Knowledge needs them for project-only and multi-repository bundles; + a project-only bundle has no implementation checkout. +- Select Codex, Claude, or both explicitly. +- Let the pinned installer update an already owned selected skill in + non-interactive mode. Remove only obsolete project-scope workflow skills + whose lock entry still identifies this package; never remove an unowned + skill by name. + +The workflow skill `analyze-with-graphify` is a routing and policy gate, not a +copy of Graphify's native skill. Require the `graphify` CLI and the official +native `graphify` skill supplied by that tool whenever source code must be +analyzed. Verify native-skill availability against the current session +catalog; an on-disk file alone does not prove the running agent loaded it. +When the CLI is absent, ask for user-level installation authority and run +`uv tool install graphifyy`. Install the native skill with `graphify install +--platform <agent>` for every selected platform, then restart the session. +`graphify install --help` is the authority for currently supported platform +names. Do not report setup success until the restarted session exposes the +native skill. +Do not require Graphify for Markdown intake or OKF curation that does not inspect +source code. + +Run `graphify update .` from the exact leaf checkout after applying an +initialization or upgrade. This graph is worktree-local evidence infrastructure: +do not reuse a sibling checkout's `graphify-out`, and do not report setup +success when the update command fails or the resulting graph has no nodes. +Maintain a `# wfctl:begin` / `# wfctl:end` block in the root `.gitignore` when +no existing exact `graphify-out/` rule already covers the artifact. Preserve +every pre-existing ignore rule. + +The QMD skill is not maintained as a workflow copy. Resolve its source with +`qmd skills path qmd`, then let the same pinned `skills` CLI copy it to the +selected Codex and Claude targets. This preserves agent-target and scope +semantics while keeping the skill matched to the installed QMD version. + +## Knowledge retrieval + +- Require QMD `>=2.5.3` for both profiles because leaf alignment reads the + linked knowledge repository and skill discovery depends on + `qmd skills path qmd`. +- Install the supported baseline through Bun when authorized: + `bun install -g @tobilu/qmd@2.5.3`. +- For a knowledge profile, let `wfctl` own `.qmd/index.yml` and + `.qmd/.gitignore`. +- Keep QMD's database and model cache out of Git. The index is disposable and + rebuildable from repository content. +- Include only `knowledge` in unscoped searches. Require explicit collection + selection for `changes`, `intake`, `reconstruction`, and `raw`. +- Run QMD from the knowledge root so it uses the project-local index. +- Run `qmd update` during knowledge initialization and upgrade so lexical BM25 + retrieval is ready before success is reported. +- Diagnose `qmd status` and `qmd doctor` separately. A working lexical index is + required. Missing models or stale embeddings are warnings until semantic or + hybrid retrieval is needed. + +## Knowledge and claim relationship graphs + +- Compile authored Markdown links and workflow relation metadata with + `wfctl knowledge build`; do not infer semantic relationships. +- Store the generated knowledge artifact at + `.workflow/current/knowledge-graph.json` and the explicit intake/ + reconstruction claim ledger at `.workflow/current/claim-ledger.json`; both + are already ignored. +- Build both during a valid knowledge initialization or upgrade. +- Make `wfctl check` fail when either artifact is missing, invalid, or stale. +- Keep QMD responsible for retrieval and Graphify responsible for source-code + structure. The compiled graphs only represent authored knowledge links and + explicit claim lineage. + +## Rules + +- Install readable copies under `.workflow/rules`. +- Mirror namespaced rule files under `.claude/rules`. +- Route Codex to `.workflow/rules` through the managed `AGENTS.md` block. + +## Maintainer guide + +- Install `PROJECT_WORKFLOW.md` as a managed, visible root document for both + profiles, using a managed block that preserves text outside the markers. +- Render profile-specific content and the configured knowledge path. +- Treat malformed or duplicated managed markers as a conflict. +- Stop for maintainer-controlled repair when markers cannot be updated safely. + Obtain the exact block with `wfctl init <kind> --print-instructions guide`. + +## Ownership + +`.workflow/state.json` records the hash of every installed owned file. Update an owned file only when the on-disk hash still matches the prior installed hash. Equal content is safe to adopt. Any other pre-existing content is a conflict. +When a later release removes an owned file, delete it only if its hash still +matches the recorded installed hash. Back it up first. A locally modified +obsolete file remains an explicit conflict. + +`.workflow/repositories.json` is a dynamic, Git-tracked project source +registry, not a generated asset. It stores repository identity without local +paths. `.workflow/current/repositories.json` is ignored and stores any number +of known local worktrees per repository plus one explicit active selection +used only by default reconstruction. Leaf initialization adds a known worktree +but never changes an existing selection. diff --git a/.claude/skills/shape-project-direction/SKILL.md b/.claude/skills/shape-project-direction/SKILL.md new file mode 100644 index 0000000..318f24e --- /dev/null +++ b/.claude/skills/shape-project-direction/SKILL.md @@ -0,0 +1,71 @@ +--- +name: shape-project-direction +description: Run deliberate Wayfinder for a consequential project initiative whose destination is visible but route, product meaning, or architecture remains too foggy for one honest specification or agent session. Use only when the maintainer explicitly requests Wayfinder or accepts the router's recommendation. Do not use for ordinary brainstorming, a bounded large feature, current-project explanation, or product-code implementation. +--- + +# Shape Project Direction + +Wayfinder finds a route; it does not build the destination. It stores one +low-resolution map and bounded question issues inside the same central bundle +that later becomes the delivery specification. + +Read [the Wayfinder contract](references/direction-shaping-contract.md) before +charting or resuming. + +## Chart the map + +1. Reuse the initiative's active bundle or start one with `wfctl work start + <slug> --title "<destination>" --mode wayfinder`. Bind only already-known + evidence leaves; unknown future implementation repositories are not guessed. +2. Run `wfctl work context <id> --stage wayfind` and `wfctl work status <id>`. + Do not edit product source during this phase. +3. Name the destination first: what a finished map will make specifiable, for + whom, and the boundary it must not cross. +4. Explore breadth-first. Put a precise answerable question into a Wayfinder + issue. Put only still-unphraseable in-scope uncertainty into `map.md` fog. + Put ruled-out work under out of scope. +5. Create currently visible blockers before their dependants with `wfctl work + issue create --phase wayfinding --type + research|prototype|grilling|task`. Use `wfctl work issue block|unblock` when + later evidence changes an edge. Stop charting; do not resolve a normal issue + in the same session. + +If breadth-first exploration reveals no meaningful fog and the whole route fits +one session, stop and recommend ordinary `specify-project-change` instead. + +## Work one frontier question + +Run `wfctl work map status <id>`. Without a named issue, choose the first +frontier item. Read the map, selected issue, parent change, blockers, and +referenced artifacts completely; record current hash receipts. Claim before +work: + +```sh +wfctl work issue claim <id> <issue-id> --actor "agent:<identity>" +``` + +- `research` discovers an external or project fact without supplying product + authority; +- `prototype` creates a cheap artifact for a real human to react to; +- `grilling` resolves one product/domain decision with the maintainer, one + evidence-backed question at a time; +- `task` performs a prerequisite that makes a later decision possible. + +Ask one focused question at a time. Persist the answer before selecting another. + +Never answer the human side of a human-in-the-loop issue yourself. Complete at +most one non-research issue per session. Record the full answer and evidence in +the issue, then run `wfctl work issue complete`. The CLI adds only a named gist +to the map. Update newly visible issues and remove the corresponding fog so one +fact never lives in two competing places. + +## Hand off to specification + +The route is clear only when every Wayfinder issue is completed or explicitly +dropped, no in-scope fog remains, and the next bounded change can be specified +without guessing. Invoke `specify-project-change`: it must read all resolved +issues, synthesize stable acceptance criteria into `change.md`, obtain review, +and run `wfctl work map finish`. + +Do not jump from a map directly into implementation. The retained `map.md` is +decision lineage, while `change.md` becomes the current delivery contract. diff --git a/.claude/skills/shape-project-direction/agents/openai.yaml b/.claude/skills/shape-project-direction/agents/openai.yaml new file mode 100644 index 0000000..26653bd --- /dev/null +++ b/.claude/skills/shape-project-direction/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Shape Project Direction" + short_description: "Resolve a large uncertain project direction" + default_prompt: "Use this skill to turn a broad, uncertain product or architecture initiative into a durable decision frontier before implementation." +policy: + allow_implicit_invocation: false diff --git a/.claude/skills/shape-project-direction/references/direction-shaping-contract.md b/.claude/skills/shape-project-direction/references/direction-shaping-contract.md new file mode 100644 index 0000000..1117873 --- /dev/null +++ b/.claude/skills/shape-project-direction/references/direction-shaping-contract.md @@ -0,0 +1,59 @@ +# Wayfinder contract + +## Entry test + +Use Wayfinder only when all are true: + +- the initiative can materially change product meaning, architecture, + ownership, or several Areas; +- the destination can be named, but multiple dependent choices still hide the + route; +- one honest specification cannot yet fit in a single well-reasoned session; +- the maintainer explicitly selected or accepted this mode. + +A large but bounded feature, migration, audit, or refactor belongs to ordinary +significant work. Brainstorming remains conversational until durable shaping is +actually wanted. + +## Map roles + +`map.md` is an index, not the store of every answer: + +- `destination` fixes what the route is finding and therefore its scope; +- `notes` holds standing domain and process context; +- `resolved` points by issue name to full issue resolutions; +- `fog` holds in-scope uncertainty that cannot yet be phrased precisely; +- `out_of_scope` holds work beyond the destination that never graduates. + +A precise unanswered question is an issue, even when blocked. A vague suspected +question remains fog. When resolution makes fog precise, create the new issue +and remove that fog item. + +## Issue roles + +- **Research (agent-driven):** establishes a fact from primary or project + sources. It cannot establish product authority. +- **Prototype (human-in-the-loop):** creates a cheap disposable artifact to + make a design question concrete. +- **Grilling (human-in-the-loop):** resolves a product, domain, or architecture + choice one focused question at a time. +- **Task (agent- or human-driven):** performs a prerequisite that exposes facts + needed by later decisions; it does not deliver the destination. + +The frontier contains ready, unclaimed issues whose blockers are completed. +Claim before work. Resolve one non-research issue per session. The full answer +and evidence live in that issue; the map stores only a gist and pointer. + +## Exit test + +Wayfinder is ready for specification only when: + +- destination and out-of-scope boundaries are explicit; +- every issue is completed or deliberately dropped with a reason; +- no in-scope fog remains; +- accepted language and decisions are distinguishable from proposals; +- the next bounded change can be specified without guessing. + +`specify-project-change` then reads the complete map bundle, synthesizes +`change.md`, and finishes the map into `full` or `slice`. It preserves `map.md` +as lineage and never bypasses specification into implementation. diff --git a/.claude/skills/specify-project-change/SKILL.md b/.claude/skills/specify-project-change/SKILL.md new file mode 100644 index 0000000..bee7312 --- /dev/null +++ b/.claude/skills/specify-project-change/SKILL.md @@ -0,0 +1,85 @@ +--- +name: specify-project-change +description: Synthesize an already-discussed bounded change, or a resolved Wayfinder map, into the one central project specification. Use when the maintainer explicitly asks to write or refresh the spec, when a direction map is ready to become delivery work, or before splitting approved work into issues. Do not use to discover a huge unresolved direction, implement code, or create a second specification outside the active knowledge bundle. +--- + +# Specify Project Change + +Turn the context already earned through discussion, curated knowledge, and +source inspection into one buildable contract. Do not restart an interview or +discard decisions merely because a fresh template would be easier. + +Read [the specification contract](references/specification-contract.md) before +editing the bundle. + +## Bind and read + +1. Resolve the active change ID. If no significant-work bundle exists, invoke + `manage-project-work` to classify and start it first. +2. Run `wfctl work context <id> --stage shape`. For a resolved Wayfinder map, + run `--stage review` so every map issue and artifact is enumerated. +3. Read every required file completely, including content below long tables or + headings. Run `wfctl work review file <id> <path>` only after the whole file + has been read and reconciled. +4. Run `wfctl work status <id>`. Treat `Spec` as the only editable + specification and every `Code root` as an exact evidence workspace, never + as a path inferred from repository name. + +## Synthesize, do not invent + +Use the current discussion without asking the maintainer to repeat it. Reconcile +it with relevant curated knowledge and verified source reality. If code informs +the contract, invoke `analyze-with-graphify` in every relevant bound root, then +open the actual source locations. Invoke `align-project-knowledge` before +settling product or architecture meaning. + +Update `change.md` with: + +- the problem and intended observable outcome; +- actors, current behavior, constraints, and explicit exclusions; +- approved product and engineering decisions with rationale; +- stable acceptance entries `AC-01`, `AC-02`, ... in frontmatter; +- the highest practical test seams and what behavior each seam proves; +- unresolved authority or facts, without guessing them away; +- the current ledger and structured resumable checkpoint. + +Acceptance criteria describe observable outcomes and boundary behavior, not a +file-by-file implementation plan. Preserve an ID when wording improves without +changing meaning. Retire or supersede changed meaning explicitly. + +Ask at most one blocking question at a time. Include verified facts, the +decision it unlocks, viable choices, and a recommendation. Persist the answer +before continuing. After each material edit or maintainer answer, refresh the +bundle checkpoint with `wfctl work checkpoint <id>`; run it last so its hash +binds the current `change.md` rather than an earlier draft. + +## Review and continue + +Present a compact framing packet: outcome, scope, exclusions, decisions, +acceptance IDs, test seams, risks, and unresolved work. Record only explicit +maintainer approval, and record it through the approval command rather than by +editing the receipt: + +```sh +wfctl work approve <id> --stage framing \ + --by human:<maintainer-id> \ + --note "<what was approved>" +``` + +The command requires an interactive terminal, or `--token` matching +`WFCTL_APPROVAL_TOKEN` in unattended use. A hand-written +`maintainer_review.framing` receipt fails verification. Approving rewrites +`change.md`, so re-read it, refresh its review receipt, and refresh the +checkpoint afterwards. + +For Wayfinder, read every resolved issue in full, collapse its linked detail +into the specification, clear all legitimate fog, review every current bundle +file, then run: + +```sh +wfctl work map finish <id> --mode full|slice +``` + +The map remains as history; it is not copied into a parallel strategy file. +After approval, invoke `split-project-change` when the work needs multiple +fresh sessions. A small bounded change may remain entirely in `change.md`. diff --git a/.claude/skills/specify-project-change/agents/openai.yaml b/.claude/skills/specify-project-change/agents/openai.yaml new file mode 100644 index 0000000..8a5543a --- /dev/null +++ b/.claude/skills/specify-project-change/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Specify Project Change" + short_description: "Synthesize one central change specification" + default_prompt: "Use $specify-project-change to synthesize the current discussion, project knowledge, and verified source context into the active central specification and refresh its checkpoint." +policy: + allow_implicit_invocation: false diff --git a/.claude/skills/specify-project-change/references/specification-contract.md b/.claude/skills/specify-project-change/references/specification-contract.md new file mode 100644 index 0000000..0bbdb26 --- /dev/null +++ b/.claude/skills/specify-project-change/references/specification-contract.md @@ -0,0 +1,29 @@ +# Specification contract + +The specification states what must become true and why. It is not a transcript, +an issue list, or an implementation diary. + +## Required synthesis + +- Explain the problem from the affected actor's perspective. +- Explain the outcome from that actor's perspective. +- Preserve the project's accepted domain language. +- Separate product behavior from engineering decisions without losing the link. +- Prefer existing high-level test seams; propose a new seam only when current + interfaces cannot prove the behavior safely. +- Record out-of-scope boundaries and known risks explicitly. +- Make every acceptance criterion observable, independently referencable, and + falsifiable. + +Do not include volatile source paths or large code snippets as the contract. +Exact source evidence belongs in progress and verification. A concise schema, +state machine, or type shape from a prototype may be retained when prose would +lose a settled decision; identify it as prototype-derived. + +## Wayfinder collapse + +The map is an index. Each completed issue owns the detailed answer. Synthesis +therefore requires reading all resolved issues, not expanding the one-line map +gists into guesses. Keep the map as lineage, carry accepted conclusions into +`change.md`, keep deferred questions visible, and do not create delivery issues +until this collapse is reviewed. diff --git a/.claude/skills/split-project-change/SKILL.md b/.claude/skills/split-project-change/SKILL.md new file mode 100644 index 0000000..b8413ae --- /dev/null +++ b/.claude/skills/split-project-change/SKILL.md @@ -0,0 +1,67 @@ +--- +name: split-project-change +description: Split one approved central change specification into dependency-aware tracer-bullet issues inside the same knowledge bundle. Use when the maintainer explicitly asks to create the execution breakdown, when delivery spans several safe agent sessions, or when parallel work needs an explicit frontier. Do not use before framing approval, for an unresolved Wayfinder map, or to create an external or leaf-local competing tracker. +--- + +# Split Project Change + +Create bounded execution units without fragmenting the specification or losing +acceptance coverage. + +Read [the issue-design contract](references/issue-design-contract.md) before +proposing the graph. + +## Read the approved contract + +1. Run `wfctl work context <id> --stage shape` and `wfctl work status <id>`. +2. Read `change.md` completely and confirm that framing is approved, the mode + is `full` or `slice`, and stable acceptance IDs exist. +3. Reuse current source and knowledge understanding. If the proposed breakdown + depends on implementation facts not yet checked, invoke Graphify-first + analysis in the exact relevant code roots before publishing tickets. + +## Draft the graph + +Prefer narrow complete tracer bullets: one issue produces independently +reviewable behavior across every necessary layer and fits in one fresh session. +Do not split work into database/API/UI horizontal layers merely because the +repository does. + +For each proposed issue show the maintainer: + +- a human-readable title; +- the complete behavior or decision it delivers; +- stable acceptance IDs it contributes to; +- exact repository identities it may touch; +- genuine blocking issues; +- whether it is small enough for one fresh context. + +Use expand-migrate-contract for a wide mechanical refactor that cannot keep the +system valid as independent vertical slices. Keep each migration batch bounded +by blast radius and make final contraction depend on every migration. + +Ask whether the granularity and dependency edges are right. Publish only the +approved graph. + +## Publish centrally + +Create issues in dependency order so blocker IDs already exist: + +```sh +wfctl work issue create <change-id> <slug> \ + --title "<title>" \ + --phase delivery \ + --type delivery \ + --satisfies AC-01 \ + --repository <repository-id> \ + --blocked-by ISSUE-001 +``` + +Repeat options as needed. Each issue receives its own ready checkpoint; never +create a second issue or progress file in a leaf. Run `wfctl work issue list +<change-id>` and confirm every acceptance ID is covered, the graph is acyclic, +and the frontier matches the intended concurrency. Refresh the parent +checkpoint last with the first executable frontier action. + +Do not implement during this skill. Hand a frontier issue to +`implement-work-item` in a fresh context. diff --git a/.claude/skills/split-project-change/agents/openai.yaml b/.claude/skills/split-project-change/agents/openai.yaml new file mode 100644 index 0000000..c6cb2a7 --- /dev/null +++ b/.claude/skills/split-project-change/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Split Project Change" + short_description: "Split a change into checkpointed issues" + default_prompt: "Use $split-project-change to split the approved central specification into dependency-aware issues with one ready checkpoint each." +policy: + allow_implicit_invocation: false diff --git a/.claude/skills/split-project-change/references/issue-design-contract.md b/.claude/skills/split-project-change/references/issue-design-contract.md new file mode 100644 index 0000000..2cb0c00 --- /dev/null +++ b/.claude/skills/split-project-change/references/issue-design-contract.md @@ -0,0 +1,27 @@ +# Issue design contract + +An issue is an executable unit, not a copy of the parent spec. + +## Good issue + +- Delivers one complete behavior or resolves one explicit work prerequisite. +- Fits in one fresh agent session when possible. +- Names the acceptance IDs it contributes to. +- Declares only dependencies that genuinely prevent starting. +- Identifies repository scope without prescribing stale file paths. +- Can be verified independently at a public or project-approved seam. +- Leaves one precise, hash-bound checkpoint after every material turn. + +## Bad splits + +- one ticket per technical layer; +- one giant issue that silently relies on conversation memory; +- blockers used merely as ordering preferences; +- acceptance text duplicated and allowed to drift; +- source paths or snippets treated as permanent requirements; +- a local leaf checklist competing with the central bundle. + +Prefactoring may be its own prerequisite issue when it makes the later behavior +safe and easy. It must still define an observable structural outcome and fresh +checks. Wide refactors use expand-migrate-contract rather than pretending each +layer is independently shippable. diff --git a/.claude/skills/verify-knowledge-quality/SKILL.md b/.claude/skills/verify-knowledge-quality/SKILL.md new file mode 100644 index 0000000..2d7e779 --- /dev/null +++ b/.claude/skills/verify-knowledge-quality/SKILL.md @@ -0,0 +1,95 @@ +--- +name: verify-knowledge-quality +description: Perform the mandatory two-axis semantic gate for curated workflow knowledge before a document becomes stable or is reported complete. Use after creating or materially editing any product, engineering, decision, reference, or uncertainty concept, or for an explicit quality audit. Review authority and truth independently from reader communication, then bind both passes to one unchanged content hash. Do not use for ordinary explanation and do not let deterministic validation, polished prose, or a single self-review satisfy both axes. +--- + +# Verify Knowledge Quality + +Act as an adversarial review coordinator, not the author defending the draft. +Truth and communication are different failure surfaces; neither may compensate +for the other. + +Read [the quality rubric](references/quality-rubric.md) before the first review +in a session. + +## Freeze the review target + +1. Read the complete target document, not a snippet. +2. Read its parent Area index, every linked current product or engineering + counterpart, every current decision that governs it, and every material + authoritative source. +3. For code-backed claims, invoke `analyze-with-graphify` in the exact pinned + leaf and directly inspect the cited source, tests, and necessary runtime + evidence. +4. Run `wfctl knowledge validate --concept <path>` and distinguish structural + failures from semantic failures. Structural success is necessary but never + sufficient. +5. Run `wfctl knowledge hash --concept <path>` and pin the candidate content + hash before semantic review. + +## Run two independent axes + +1. Apply [the authority and truth review](references/authority-review.md). +2. Separately apply + [the reader communication review](references/reader-communication-review.md) + for the declared view and audience. +3. Use independent reviewer contexts when the runtime safely provides them. + Otherwise perform two explicitly separated passes from their own checklists + and evidence packets. Do not reuse the first pass's verdict as evidence for + the second. +4. Search across both passes for omitted exceptions, unsupported present + tense, mixed audiences, hidden implementation detail, intent inferred from + code, history presented as current, and claims broader than evidence. +5. Return one review packet with: + - result: passed or failed; + - separate authority-truth and reader-communication results; + - each failed, uncertain, unread, or blocked check; + - exact evidence and conflicting evidence; + - the smallest correction; + - authority needed from the maintainer, if any. +6. Recompute the content hash. If it changed, discard both passes and rerun + them on the new revision. +7. Do not write a passed receipt while any item is failed, uncertain, unread, + or blocked. + +## Record a passed receipt + +After all substantive content is final: + +1. Run `wfctl knowledge hash --concept <path>`. +2. Set: + +```yaml +x-wf: + quality: + status: passed + by: "<producer>/<version>" + at: "<ISO-8601>" + content_hash: "<wfctl knowledge hash output>" + checks: + - factuality + - audience-fit + - abstraction + - completeness + - delivery-state + axes: + authority-truth: + status: passed + by: "<producer>/<version>" + at: "<ISO-8601>" + content_hash: "<same wfctl knowledge hash output>" + reader-communication: + status: passed + by: "<producer>/<version>" + at: "<ISO-8601>" + content_hash: "<same wfctl knowledge hash output>" +``` + +3. Use the same hash in the normal `verified` event after the applicable + machine or human authority review. +4. Re-run `wfctl knowledge validate --concept <path>`. A material edit changes + the hash and invalidates both receipts. + +The receipt proves that the declared review was performed against one exact +document revision. It does not create authority and does not make an incorrect +review true. diff --git a/.claude/skills/verify-knowledge-quality/agents/openai.yaml b/.claude/skills/verify-knowledge-quality/agents/openai.yaml new file mode 100644 index 0000000..7d58a0f --- /dev/null +++ b/.claude/skills/verify-knowledge-quality/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Verify Knowledge Quality" + short_description: "Review truth and communication independently" + default_prompt: "Use $verify-knowledge-quality to run separate authority-truth and reader-communication reviews against one unchanged knowledge content hash." diff --git a/.claude/skills/verify-knowledge-quality/references/authority-review.md b/.claude/skills/verify-knowledge-quality/references/authority-review.md new file mode 100644 index 0000000..47ce721 --- /dev/null +++ b/.claude/skills/verify-knowledge-quality/references/authority-review.md @@ -0,0 +1,35 @@ +# Authority and truth review + +Review whether the document is entitled to make each claim. Ignore elegance, +tone, and readability except where ambiguity changes meaning. + +## Evidence packet + +Read: + +- the complete target; +- every governing current decision; +- every cited authoritative source in full enough to test the claim; +- current counterpart views and contradictory evidence; +- pinned source, tests, and runtime receipts for implementation claims. + +Retrieval snippets, raw, intake prose, compiled graphs, and agent summaries are +navigation only. + +## Checks + +- Split material statements into atomic claims. +- Match each claim to the correct authority class. +- Confirm source identity, revision, scope, and freshness. +- Ensure accepted intent, observed delivery, and alignment remain independent. +- Reject claims broader than their evidence. +- Find missing conditions, exceptions, failure modes, non-goals, and + contradictions. +- Verify that history is historical and current truth is current. +- Verify decision lineage and explicit maintainer authority for normative + meaning. +- Treat negative or absence claims as unproven without complete applicable + coverage. + +Return `passed`, `failed`, `uncertain`, or `blocked` per check. A missing or +unread source cannot pass. diff --git a/.claude/skills/verify-knowledge-quality/references/quality-rubric.md b/.claude/skills/verify-knowledge-quality/references/quality-rubric.md new file mode 100644 index 0000000..d63ef5f --- /dev/null +++ b/.claude/skills/verify-knowledge-quality/references/quality-rubric.md @@ -0,0 +1,102 @@ +# Knowledge quality rubric + +Every item is `passed`, `failed`, `uncertain`, or `blocked`. Only an all-passed +review may create a quality receipt. + +Run the common truth checks as the `authority-truth` axis and the view-specific +checks as the `reader-communication` axis. Bind both results to the same +unchanged content hash. Deterministic validation is a separate structural +gate. + +## Authority-truth axis + +### Factuality + +- Every material current claim has matching authority. +- Sources were read directly and match the exact revision. +- Raw, intake, retrieval snippets, generated graphs, and agent prose are not + treated as authority. +- Conflicting evidence and uncertainty are visible. +- Claims do not exceed the scope of their evidence. + +### Delivery state + +- Accepted intent, observed delivery, and alignment are independent. +- Present tense does not imply unavailable behavior. +- `absent`, `partial`, `implemented`, `verified`, `retired`, and `unknown` + match the actual evidence. +- Planned and rejected behavior remains outside current knowledge. + +### Completeness + +- Important rules, outcomes, boundaries, exceptions, non-goals, failure modes, + and affected relationships were not dropped. +- The document is the smallest coherent unit, not a fragment that hides a + material condition. +- Current decisions and supersession lineage are linked. + +### Freshness and lineage + +- `generated.at`, sources, realization assessment, and linked decisions refer + to the same current state. +- Historical explanation is clearly historical. +- A changed decision updates the current view and preserves predecessor + lineage. + +## Reader-communication axis + +### Product-view checks + +### Audience fit + +- A product manager or client can understand the main answer without + engineering knowledge. +- Domain terms are explained. +- The document leads with product outcome and observable behavior. +- Examples are domain examples, not code or API examples. + +### Abstraction + +- No code, identifiers, paths, endpoints, schemas, protocols, storage + mechanisms, package names, or implementation walkthroughs appear. +- `Engineering details` is link-only. +- Replacing the implementation without changing behavior would not require + rewriting the product explanation. +- Simplification did not erase a rule or exception. + +### Engineering-view checks + +### Audience fit + +- An engineer or operator can locate ownership, implementation, flow, + contracts, failure behavior, and verification. +- Technical terms are precise and useful for maintenance. + +### Abstraction + +- Product meaning is linked, not reconstructed from code. +- The document explains implementation rather than duplicating product prose. +- Technical detail is proportional to maintenance and verification needs. +- Repository, revision, paths, tests, and runtime evidence are pinned where + material. + +### Decision-view checks + +- Context, exact decision, rationale, consequences, affected knowledge, and + lineage are present. +- Alternatives, transition, and unresolved questions are concise and honest + when material; the document does not invent ceremonial content. +- Product effect is understandable without implementation detail. +- Technical consequences link to engineering knowledge. +- Supersession is reciprocal, acyclic, and leaves one stable current record. + +## Evaluation discipline + +This rubric follows agent-evaluation practice: inspect both outcome and +process, combine deterministic checks with semantic and human review, and test +real failure cases rather than relying on self-assessment. + +- Anthropic agent evals: + https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents +- Anthropic skill creation and baseline comparison: + https://github.com/anthropics/skills/blob/main/skills/skill-creator/SKILL.md diff --git a/.claude/skills/verify-knowledge-quality/references/reader-communication-review.md b/.claude/skills/verify-knowledge-quality/references/reader-communication-review.md new file mode 100644 index 0000000..f26e892 --- /dev/null +++ b/.claude/skills/verify-knowledge-quality/references/reader-communication-review.md @@ -0,0 +1,40 @@ +# Reader communication review + +Review whether the intended reader can recover the correct meaning without +hidden context. Do not accept a document merely because its evidence is strong. + +## Product view + +Evaluate as a product manager, client, maintainer, or domain expert: + +- the purpose and current behavior are clear before history; +- canonical domain terms are defined and used consistently; +- actors, rules, outcomes, boundaries, exceptions, and delivery are visible; +- examples are recognizable domain scenarios; +- technical details do not leak into the explanation; +- planned, partial, absent, retired, and unknown behavior cannot be mistaken + for available behavior; +- links provide optional depth without being required to understand the main + answer. + +## Engineering view + +Evaluate as an engineer or operator: + +- product meaning is linked rather than re-invented; +- ownership, entrypoints, flow, contracts, state, failures, operations, and + verification can be located; +- terminology and boundaries are precise; +- implementation detail is proportional to maintenance needs; +- evidence and revision scope are clear. + +## Decision, reference, and uncertainty views + +- a decision explains the durable choice, rationale, consequences, and + lineage without ceremonial padding; +- a reference distinguishes external fact from project choice; +- an uncertainty states the live question, impact, known facts, and required + authority or evidence. + +Return `passed`, `failed`, `uncertain`, or `blocked` per check. Identify the +smallest wording or structural correction, but do not silently change facts. diff --git a/.claude/skills/verify-project-work/SKILL.md b/.claude/skills/verify-project-work/SKILL.md new file mode 100644 index 0000000..cb47054 --- /dev/null +++ b/.claude/skills/verify-project-work/SKILL.md @@ -0,0 +1,106 @@ +--- +name: verify-project-work +description: Verify a significant project change against every file in its central bundle, every stable acceptance ID, and every exact bound source revision without hiding gaps. Use before claiming a bounded change, slice, multi-repository delivery, project-only decision, or architecture change complete; before knowledge promotion; or when auditing an implementation against its approved contract. +--- + +# Verify Project Work + +Completion is a claim supported by complete accounting and fresh semantic +evidence. Structural green output is never proof by itself. + +Read [the completion gate](references/completion-gate.md) before recording final +receipts. + +## Account for the whole bundle + +1. Run `wfctl work status <id>` and stop on any checkout, branch, worktree, or + binding mismatch. +2. Run `wfctl work context <id> --stage review`. +3. Read every required file completely, including long issue tails and every + artifact that informs the result. Mark only a genuinely irrelevant + supporting artifact `irrelevant`, with a reason. +4. After each read, record `wfctl work review file <id> <path>`. Re-read and + refresh a receipt whenever the file changes. +5. Run `wfctl work review status <id>` and require zero unseen, + changed-after-review, or invalid files. + +## Review on independent axes + +**Contract axis:** map every stable acceptance ID to non-dropped issues, +production behavior, direct evidence, and a verification receipt. Find missing +requirements, partial behavior, scope creep, and implementation that appears +present but contradicts the contract. + +**Engineering axis:** inspect the real diff and production path in every exact +code root. Invoke Graphify-first analysis, then open actual source, callers, +boundaries, state, errors, and consumers. Check project standards, architecture, +security, operations, and maintainability independently of whether the spec was +followed. + +Run focused behavior checks and the broader relevant test, build, type, lint, +and runtime checks. Expected values must come from the contract or an +independent authority, not the implementation. Look adversarially for disabled +paths, placeholders, mocks, fixtures, temporary compatibility code, unhandled +branches, and silently deferred work. + +Record one exact repository/revision/worktree receipt per bound leaf. Require a +clean commit containing the reviewed implementation; obtain normal maintainer +authorization before committing. For project-only work, verify decisions, +knowledge, and links without inventing code evidence. + +## Promote and close + +Decide whether verified durable truth changed. If yes, route product behavior +and engineering realization through their separate curation skills, run the +two-axis knowledge quality gate, validate every changed concept, and list exact +concepts under `knowledge_promotion`. Otherwise record a concrete no-update +reason. + +A concept promoted from a bundle in a project with no reconstructed baseline +carries the same shape as one established by whole-project reading and a much +narrower footing: it was derived from whatever this task happened to touch. Say +so in its `maintainer-decision` source — name the bundle and state that no +reconstruction has covered this subject — so a later baseline knows to re-derive +it rather than treat it as already settled. Promote it anyway: knowledge grown +from real work is better than none, and the cost of the shortcut is only hidden +when nobody writes it down. + +Present acceptance results, engineering findings, checks, deviations, risks, +and knowledge delta as one completion review packet. Record the maintainer's +explicit decision through the approval command; never write the receipt by +hand: + +```sh +wfctl work approve <id> --stage completion \ + --by human:<maintainer-id> \ + --note "<what the maintainer accepted>" +``` + +It requires an interactive terminal, or `--token` matching +`WFCTL_APPROVAL_TOKEN`. `wfctl work verify` rejects a receipt with no matching +approval record. Finish all semantic edits to `change.md`, then refresh its +checkpoint in review stage **before** recording the final hash receipt: + +```sh +wfctl work checkpoint <id> \ + --actor "agent:<identity>" \ + --stage review \ + --state "Final verification and maintainer decision are recorded." \ + --last "Reconciled acceptance, implementation, and knowledge promotion." \ + --next "Re-read changed bundle files, refresh their receipts, and run the completion gate." +``` + +Re-read `change.md` completely after that command and refresh its receipt, plus +every other file changed by the review. Require a current checkpoint and zero +unseen, changed-after-review, or invalid files. Then run: + +```sh +wfctl work verify <id> +wfctl work close <id> --outcome completed|partial|abandoned +``` + +Use the honest outcome. Completed closure fails on open issues or claims, +unresolved Wayfinder state, acceptance gaps, stale file receipts, dirty or +mismatched source revisions, a stale checkpoint, missing evidence, or +incomplete promotion. Closing the bundle makes its checkpoint terminal; do not +create a capture for this completed session state. diff --git a/.claude/skills/verify-project-work/agents/openai.yaml b/.claude/skills/verify-project-work/agents/openai.yaml new file mode 100644 index 0000000..548e7dc --- /dev/null +++ b/.claude/skills/verify-project-work/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Verify Project Work" + short_description: "Verify every bound source against one spec" + default_prompt: "Use $verify-project-work to verify project-only knowledge work or every bound leaf checkout against the central spec and recorded evidence." diff --git a/.claude/skills/verify-project-work/references/completion-gate.md b/.claude/skills/verify-project-work/references/completion-gate.md new file mode 100644 index 0000000..a1cd1fe --- /dev/null +++ b/.claude/skills/verify-project-work/references/completion-gate.md @@ -0,0 +1,44 @@ +# Completion gate + +A completed record requires: + +- every plan and acceptance checkbox resolved; +- the change and every relevant issue have a current structured checkpoint; +- every stable acceptance ID marked verified and paired with passed evidence; +- every bundle file except the review ledger accounted for at its current hash; +- no unseen, changed-after-review, or invalid bundle file; +- every work issue completed or explicitly dropped, with no active claim; +- every acceptance ID covered by a non-dropped delivery issue when issues are used; +- no issue dependency cycle or completed issue with an unresolved blocker; +- any retained Wayfinder map resolved, with no remaining fog; +- at least one relevant Graphify query recorded for code-scoped work; +- `acceptance_reviewed: true`; +- `implementation_reviewed: true` for code-scoped work, or + `knowledge_reviewed: true` for project-only work; +- `maintainer_review.framing.status: approved` with a human actor and timestamp; +- `maintainer_review.completion.status: approved` with a human actor and timestamp; +- `knowledge_promotion.status: applied` with validated concept paths, or + `not-needed` with a concrete reason; +- one or more fresh checks with commands and outcomes; +- `verification.result: passed`; +- no unresolved item without an explicit accepted disposition; +- a deviations section that says `None` or names every remaining gap. +- every bound source checkout clean so each recorded commit actually contains + its verified implementation; +- a matching revision, worktree ID, and checks receipt for every bound + repository. Single-leaf work may use the top-level verification fields; + multi-repository work uses `verification.repositories`. + +Update semantic records first, refresh the owning checkpoint last, then re-read +the changed record and record its final file receipt. A checkpoint edit changes +the file hash; recording the receipt before the checkpoint would immediately +make that receipt stale. + +The CLI validates the bundle graph, file hashes, record structure, exact source +bindings, and any promoted concept files. It +cannot prove that a conversation occurred, that semantic evidence is correct, +or that no material claim was omitted. The agent must record a maintainer +approval only after an explicit decision, and the maintainer remains +responsible for that decision. + +If a requirement is intentionally dropped, update scope and record who accepted the change. Do not merely check it off. diff --git a/.gitignore b/.gitignore index 294cd45..462ace7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ storybook-static/ # Config files .webextrc .webextrc.* + +# wfctl:begin +graphify-out/ +# wfctl:end diff --git a/.graphifyignore b/.graphifyignore new file mode 100644 index 0000000..24169ca --- /dev/null +++ b/.graphifyignore @@ -0,0 +1,39 @@ +# wfctl:begin +.workflow/ +.claude/rules/ +graphify-out/ +AGENTS.md +CLAUDE.md +PROJECT_WORKFLOW.md +skills-lock.json +.agents/skills/align-project-knowledge/ +.claude/skills/align-project-knowledge/ +.agents/skills/analyze-with-graphify/ +.claude/skills/analyze-with-graphify/ +.agents/skills/curate-engineering-knowledge/ +.claude/skills/curate-engineering-knowledge/ +.agents/skills/curate-product-knowledge/ +.claude/skills/curate-product-knowledge/ +.agents/skills/curate-project-knowledge/ +.claude/skills/curate-project-knowledge/ +.agents/skills/explore-project-knowledge/ +.claude/skills/explore-project-knowledge/ +.agents/skills/implement-work-item/ +.claude/skills/implement-work-item/ +.agents/skills/manage-project-work/ +.claude/skills/manage-project-work/ +.agents/skills/qmd/ +.claude/skills/qmd/ +.agents/skills/setup-workflow-environment/ +.claude/skills/setup-workflow-environment/ +.agents/skills/shape-project-direction/ +.claude/skills/shape-project-direction/ +.agents/skills/specify-project-change/ +.claude/skills/specify-project-change/ +.agents/skills/split-project-change/ +.claude/skills/split-project-change/ +.agents/skills/verify-knowledge-quality/ +.claude/skills/verify-knowledge-quality/ +.agents/skills/verify-project-work/ +.claude/skills/verify-project-work/ +# wfctl:end diff --git a/.workflow/.gitignore b/.workflow/.gitignore new file mode 100644 index 0000000..ef1569e --- /dev/null +++ b/.workflow/.gitignore @@ -0,0 +1,2 @@ +backups/ +current/ diff --git a/.workflow/config.json b/.workflow/config.json new file mode 100644 index 0000000..90dc1f4 --- /dev/null +++ b/.workflow/config.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "profile": "leaf", + "installedVersion": "0.8.0", + "skills": { + "scope": "project", + "agents": [ + "codex", + "claude" + ] + }, + "knowledge": { + "path": "../knowledge-humid" + } +} diff --git a/.workflow/rules/evidence-first.md b/.workflow/rules/evidence-first.md new file mode 100644 index 0000000..f4db33e --- /dev/null +++ b/.workflow/rules/evidence-first.md @@ -0,0 +1,60 @@ +# Evidence-first reasoning + +Treat claims, plans, existing documents, and prior agent output as unverified until checked. + +For any task that depends on understanding, locating, changing, debugging, +reviewing, or verifying source code: + +1. Invoke `analyze-with-graphify` before reading or searching source code, even + when the maintainer does not mention Graphify. +2. Require it to inspect the current session skill catalog and invoke the + official native `graphify` skill. +3. Stop and tell the maintainer when either the Graphify CLI or native session + skill is unavailable. Offer the supported installation and session-restart path. +4. Use text search only after graph traversal, as a supplementary precision tool. +5. Inspect the actual source reached through the graph and record the query, + pinned revision, paths, symbols, and checks in the active change or curation + record. Graphify output is navigation, not authority. + +Do not use Graphify as the primary analyzer for raw or curated Markdown. +Use QMD for Markdown retrieval and the generated knowledge graph for explicit +relationship expansion. Use the generated claim ledger only for explicit +intake/reconstruction lineage. Neither graph is evidence; read selected files +directly. Before +knowledge-dependent work, require the official native `qmd` skill in the +current session and invoke it. An on-disk skill installed after session start +does not count; stop and request installation or a session restart instead of +inventing a partial QMD procedure. Raw coverage comes from Git-frozen intake +sources and explicit full-file review. Existing-project coverage comes from +exact clean source revisions, Graphify traversal, direct source and test +inspection, Git history review, repository dossiers, cross-repository +reconciliation, and maintainer adjudication. Curated trust comes from OKF +metadata, authoritative provenance, and current verification. QMD rank, +snippets, and its index are never authority. + +Assume no source is in good condition. Documents may reference deleted paths, +specifications may have been rewritten without supersession, notes meant to be +temporary may be the only written intent, and history may record file moves +rather than decisions. Judge each source as it actually is in this project, not +as its kind is supposed to be. + +When a source turns out to be unreliable, that narrows what it can establish and +widens nothing else. Do not answer it by electing a replacement authority: every +source stays a witness, and reconciling them is the work. Keep reading the +degraded source for terminology, chronology, and leads. Where it contradicts +itself over time, reconcile by chronology rather than choosing a version. + +When no source can establish something, say so and record it as unknown. An +honest unknown is a result; a plausible answer assembled from the least-bad +source available is a guess the maintainer can no longer see through. + +Never establish that work is progressing by checking that a process exists, and +never identify a process by matching its name: the pattern matches the shell +doing the checking, so a command that is waiting on itself reports as running. +Ask the data instead — the counters, records, or files the job writes. When a +background command is reported silent, that is a prompt to check, not a finding. +Compare consumed CPU time against elapsed time, read what the job has written, +and only then decide. Do not agree that something is broken because it was +reported, and do not restart healthy work; that costs more than waiting. + +Do not turn mocks, fixtures, fakes, placeholders, disabled checks, or partial wiring into production completion claims. Name missing evidence and unfinished work directly. diff --git a/.workflow/rules/execution-continuity.md b/.workflow/rules/execution-continuity.md new file mode 100644 index 0000000..c338666 --- /dev/null +++ b/.workflow/rules/execution-continuity.md @@ -0,0 +1,37 @@ +# Execution continuity + +This applies while executing accepted work: a claimed issue, an approved scope, +a frozen frontier. It does not apply while shaping, specifying, or grilling a +decision with the maintainer. There the question count should be high, one +focused question at a time is correct, and stopping to ask is the work itself. + +Ending a turn is an act, not a default. The whole corpus says how to ask the +maintainer and nothing says when not to, so an unowned pause reads as safe when +it is only cheap. While accepted work remains, hold three lines. + +**Find it before asking it.** Locate the question in the accepted material — +the specification, the issue, the discovery ledger, the parent bundle's +decisions, the case record — and quote the line that answers it. A question the +material already answers is not a question. Absence of a quote is not permission +to ask; it is evidence the search was not done. + +**A report is not the finish line.** Completion is defined by the issue's +acceptance criteria and proven by its terminal status. A finished plan item, a +written summary, a passing subtask, and a refreshed checkpoint are progress. +Never announce the next action and then stop: either take it, or record why it +cannot be taken. + +**A discovery is not a stop.** Material that contradicts what was accepted is an +entry in the discovery ledger, and the work continues. Stop only when the +decision is irreversible beyond the current bundle, or when no unblocked work +remains — repeated identical failure, unavailable authority, a blocked source. +Discomfort and uncertainty are not on that list. Record the uncertainty as +uncertainty and keep going. + +Questions that genuinely need the maintainer accumulate and are presented at the +frontier, the batch boundary, or review. Adjudication is a recorded claim +awaiting authority, not a halt: continue with every unit that does not depend on +the answer. + +Do not answer this rule with agreement, restate it, or confirm that it is +correct. Apply it and take the next action. diff --git a/.workflow/rules/maintainer-review.md b/.workflow/rules/maintainer-review.md new file mode 100644 index 0000000..7da7321 --- /dev/null +++ b/.workflow/rules/maintainer-review.md @@ -0,0 +1,43 @@ +# Maintainer review + +OKF records provenance, trust, and lifecycle; it does not provide an approval +process. Follow the project review protocol in `PROJECT_WORKFLOW.md`. + +Require an explicit maintainer decision before: + +- choosing a workflow route when significance is ambiguous; +- starting raw processing, whole-project reconstruction, durable external + research, semantic curation, or broad direction shaping when the maintainer + did not already request that outcome; +- implementing a significant spec whose outcome, scope, exclusions, acceptance + criteria, or material decisions have not already been explicitly accepted; +- selecting current truth when evidence cannot resolve chronology or authority; +- accepting material re-scoping, unresolved risk, or a completion claim; +- recording `verified` by a `human:<id>` actor. + +Do not request review for freezing a clean Git raw scope, file accounting, QMD +refresh, index or log maintenance, or source-backed drafts that keep their +trust state honest. Raw intake itself never counts as evidence. + +Present a compact review packet containing the exact decision, evidence, +conflicts, recommendation, and requested response. Record approve, correct, or +defer. Ask one focused question at a time. Silence is not approval. + +For significant work, record framing and completion decisions with `wfctl work +approve <id> --stage framing|completion --by human:<maintainer-id>`. That +command needs an interactive terminal, or an out-of-band `--token` matching +`WFCTL_APPROVAL_TOKEN`; it writes both the `maintainer_review` receipt and the +durable approval record the completion gate checks. Never hand-write +`maintainer_review.status`, `by`, `at`, `method`, or `receipt`: a hand-written +receipt fails verification. Existing explicit maintainer instructions may +satisfy the framing decision, but still record it through the command; do not +ask for the same decision twice. Reopen the gate when the approved framing +changes materially. + +Approving edits the change record, so re-read it, refresh its review receipt, +and refresh the checkpoint afterwards. + +After every material maintainer turn, update the spec's mutable current state +and append the decision/discussion ledger before continuing. Preserve proposed, +approved, rejected, deferred, and superseded outcomes without copying the +conversation transcript. diff --git a/.workflow/rules/project-work-bundles.md b/.workflow/rules/project-work-bundles.md new file mode 100644 index 0000000..f68d634 --- /dev/null +++ b/.workflow/rules/project-work-bundles.md @@ -0,0 +1,43 @@ +# Central project work bundles + +The agent owns routine `wfctl work` commands and structured records. The +maintainer supplies intent, corrections, authority, and explicit review +decisions; do not make them operate the tracker. + +For significant work, create exactly one bundle under +`changes/active/<change-id>/` before extended discussion. `change.md` is the +parent contract, `map.md` is optional Wayfinder lineage, `issues/` contains +bounded work, `artifacts/` contains referenced support, and `review.md` records +full-file accounting. A leaf stores only an ignored binding pointer. + +After every material maintainer turn or agent investigation cycle, preserve any +new information whose loss could cause repeated material investigation, a +different choice, misunderstanding, or unsafe action. Append it to the owning +change or issue `Discovery ledger` with observation, evidence, implication, +scope, and disposition. The ledger is not a fixed taxonomy or activity log. +Then update current state, decisions, acceptance, progress, and evidence, and +run `wfctl work checkpoint` last. A stale checkpoint blocks later gates. + +After interruption, compaction, or a clean-session start, run `wfctl work +context --stage resume` without an ID. Auto-select only when exactly one active +record is bound here; when several exist, inspect `wfctl work status` and ask +the maintainer rather than guessing. Inspect the reported checkpoint, read +every required file and discovery entry completely, verify the exact claim and +code roots, and resume from the bundle rather than conversation memory. + +Use `changes/inbox/` only for pending captures that have no active or curated +owner. Never duplicate active progress there. Resolve each capture to existing +destinations or discard it with a reason so the inbox remains a real queue. + +Before claiming an issue, record a current review receipt for every required +context file. Claim from the exact bound leaf before code work. Never infer a +worktree from repository name, branch, sibling paths, or bundle location. + +Wayfinder is deliberate and planning-only. It resolves precise question issues +and fog into a reviewed specification before any delivery issue or product-code +implementation begins. + +Before completed closure, enumerate the entire bundle with `wfctl work context +--stage review`, re-read every file, refresh stale receipts, reconcile every +stable acceptance ID against direct evidence, promote durable truth separately, +and obtain explicit maintainer completion approval. diff --git a/.workflow/rules/project-work-lifecycle.md b/.workflow/rules/project-work-lifecycle.md new file mode 100644 index 0000000..5420db9 --- /dev/null +++ b/.workflow/rules/project-work-lifecycle.md @@ -0,0 +1,44 @@ +# Leaf project work lifecycle + +This checkout is an implementation surface. Its configured knowledge +repository owns significant-work bundles and current project knowledge. + +1. Route the request with `manage-project-work`. +2. For significant work, create or reuse one central bundle and run `wfctl work + status` plus the stage-specific `wfctl work context`. +3. Use `specify-project-change` for a bounded contract or + `shape-project-direction` only for explicit Wayfinder. +4. Align the contract with curated knowledge and analyze source Graphify-first. + An empty or uncovering corpus is a supported state in an existing project: + report that nothing curated covers this work and what the alignment rested on + instead, rather than reporting no conflicts. Recommend a reconstruction when + the gap is material; never treat one as a precondition for the work. +5. Record explicit framing approval before code edits. +6. Use `split-project-change` for multi-session work. It creates central issues, + never leaf-local tickets. +7. Use `implement-work-item` for one frontier issue. Read every required file, + record current receipts, and claim the issue from this exact checkout before + implementation. +8. After material discussion or investigation, preserve consequential new + understanding in the owning record's broad `Discovery ledger`, update the + affected semantic state, then refresh its structured checkpoint last. Never + copy active progress into the inbox. +9. Verify behavior through direct production-path inspection and fresh checks. +10. Preserve implementation in the exact clean Git commit only with normal + maintainer authorization. +11. Run `verify-project-work` across the whole bundle and every bound source + revision, promote verified durable truth, obtain completion approval, and + close honestly. + +On a clean session or resume, run `wfctl work context --stage resume` without an +ID. It may auto-select only one bound active record; multiple records require a +maintainer choice. Read its complete required-file set and discovery ledgers, +then verify status. Before code edits, after any directory or branch change, +after compaction, and before verification, re-run work status. Every reported +code root is an exact workspace. The returned bundle/spec path is for records +only. A worktree is not interchangeable with another checkout of the same +repository. + +Do not claim completion with unseen or stale bundle files, open issues or +claims, uncovered acceptance IDs, unresolved Wayfinder fog, dirty checkouts, or +missing evidence. Use partial or abandoned outcomes instead of relabeling gaps. diff --git a/.workflow/rules/workflow-routing.md b/.workflow/rules/workflow-routing.md new file mode 100644 index 0000000..bfbf0aa --- /dev/null +++ b/.workflow/rules/workflow-routing.md @@ -0,0 +1,28 @@ +# Workflow routing + +Classify work before changing product or implementation state. Creating a +`shaping` record is safe recordkeeping, not implementation. + +A read-only question about the project, its current capabilities, or one +product direction is not implementation work. Invoke +`explore-project-knowledge` against the configured knowledge repository and +answer progressively. Do not create a shaping record unless the conversation +turns into a proposed decision or change. + +Use the full workflow when work may change observable behavior, domain meaning, interfaces, data or control flow, persistent state, security, reliability, architecture, operational behavior, or coordination across components or repositories. + +Skip the full workflow only when the change is clearly local and preserves behavior and contracts, such as presentation-only polish, copy edits, formatting, or a mechanical correction with no design choice. + +If classification is uncertain, explain the possible impact and ask the maintainer whether to use the full workflow. Recommend one answer. If the maintainer declines, proceed lightly and offer a pending capture only when a reusable finding has no existing owner. Use `wfctl work capture add`; never duplicate active work in `changes/inbox/` or present the capture as authoritative history. + +Never use task size alone as the classifier. A one-line contract change can be significant; a large mechanical rewrite can be lightweight. + +Once work is classified as significant, create its central change bundle before +extended discussion so requirements, issues, decisions, and progress survive +session compaction. + +When a consequential initiative is too uncertain to define acceptance criteria +without guessing across several dependent choices, recommend +`shape-project-direction`. Start Wayfinder only after maintainer agreement, +keep its map and issues in the same central bundle, and do not edit code until +the resolved route has been synthesized into a bounded specification. diff --git a/.workflow/runtime/guard-background-bash.mjs b/.workflow/runtime/guard-background-bash.mjs new file mode 100644 index 0000000..ffbd460 --- /dev/null +++ b/.workflow/runtime/guard-background-bash.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// PreToolUse hook: put a stall watch around every shell command. +// +// A background command has no deadline and no stall detection, so one that +// stops progressing is never heard from again. Foreground commands are not +// exempt: the host moves one to the background once it runs long enough, and +// the watch has to be in place before that happens rather than after. Wrapping +// everything is also what keeps this free of guesses about which commands are +// worth watching. +// +// The watch does not judge the command; it reports so the agent can check. +// +// This runs before every tool call, so it must never fail and never block: +// any unexpected input produces no decision and the call proceeds unchanged. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const GUARD = join(dirname(fileURLToPath(import.meta.url)), "idle-guard.sh"); +const IDLE_SECONDS = process.env.WFCTL_IDLE_GUARD_SECONDS || "600"; + +function shellQuote(value) { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function main() { + let payload; + try { + payload = JSON.parse(readFileSync(0, "utf8")); + } catch { + return; + } + if (payload?.tool_name !== "Bash") { + return; + } + const input = payload.tool_input; + if (!input) { + return; + } + const command = typeof input.command === "string" ? input.command : ""; + if (!command.trim() || command.includes("idle-guard.sh")) { + return; + } + + process.stdout.write(`${ + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + updatedInput: { + ...input, + command: `IDLE=${IDLE_SECONDS} bash ${shellQuote(GUARD)} --shell ${ + shellQuote(command) + }`, + }, + }, + }) + }\n`); +} + +try { + main(); +} catch { + // A hook that fails is worse than a hook that abstains. +} +process.exit(0); diff --git a/.workflow/runtime/guard-stop.mjs b/.workflow/runtime/guard-stop.mjs new file mode 100644 index 0000000..d70c9d2 --- /dev/null +++ b/.workflow/runtime/guard-stop.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +// Stop hook. A turn that ends on a stated next action is the most common way +// autonomous work dies: nothing is blocked, nothing failed, and the transcript +// simply stops. Instructions do not fix it — the managed agent block already +// says "announce it and continue" and is ignored. What fixes it is costing the +// model another turn, because inside that turn the announced action is the +// cheapest thing to do. +// +// This never decides whether the work is done. It reports what the turn ended +// with and what the repository says is outstanding, and hands the judgment +// back. Deciding completion here is exactly how a Stop hook burns a session: +// a hook that keeps answering "not finished" forces turns the model cannot +// satisfy until the token cap ends it. +// +// The bound is progress rather than a single re-entry. One re-entry was the +// first attempt and it was too weak: an agent re-entered once, did real work, +// stopped again, and the second stop passed unconditionally, so the run parked +// itself for the night with the frontier still full. Progress is observable +// without judging anything — the state report either moved between two stops or +// it did not — so re-entry continues while the repository keeps changing and +// releases the moment it stops, under a hard ceiling that guarantees the turn +// always ends. +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const MESSAGE_LIMIT = 600; +const MAX_REENTRIES = 6; + +function allow() { + process.exit(0); +} + +function main() { + let input; + try { + input = JSON.parse(readFileSync(0, "utf8")); + } catch { + allow(); + return; + } + + // Waiting on a background task is a legitimate reason for a short turn; the + // host re-invokes the agent when the task finishes. + if (Array.isArray(input.background_tasks) && input.background_tasks.length > 0) { + allow(); + return; + } + + const cwd = input.cwd || process.cwd(); + const report = readState(cwd); + if (!report) { + allow(); + return; + } + // Every signal that awaits the agent arms this, including the ones that look + // like housekeeping. Filtering by level was the wrong trade: a spent turn + // costs seconds and the failure it catches costs a day. A signal awaiting the + // maintainer stays out — that is a question for them, and forcing a turn on + // it would only make the agent answer itself. + const awaiting = (report.signals ?? []).filter((signal) => signal.awaits === "agent"); + if (awaiting.length === 0) { + allow(); + return; + } + + const fingerprint = stateFingerprint(report); + const key = `${input.session_id ?? ""}:${input.prompt_id ?? ""}`; + const previous = readMemory(cwd); + const carried = previous.key === key + ? previous + : { key, count: 0, fingerprint: "", answer: "" }; + const answer = createHash("sha256") + .update(input.last_assistant_message ?? "") + .digest("hex"); + + const remembered = writeMemory(cwd, { + key, + count: carried.count + 1, + fingerprint, + answer, + }); + if (input.stop_hook_active) { + // Without durable memory there is no way to tell a productive continuation + // from a stuck one, so fall back to the weaker single re-entry rather than + // risk a turn that cannot end. + if (!remembered) { + allow(); + return; + } + if (carried.fingerprint === fingerprint) { + // The last re-entry changed nothing the repository can see. Asking again + // would be asking the same question of the same state. + writeMemory(cwd, { key, count: 0, fingerprint, answer }); + allow(); + return; + } + if (carried.answer === answer) { + // The repository moved but the agent gave the same answer, which is what + // a genuinely stuck one does while something else writes underneath it. + writeMemory(cwd, { key, count: 0, fingerprint, answer }); + allow(); + return; + } + if (carried.count >= MAX_REENTRIES) { + // State that keeps moving for reasons unrelated to this turn would + // otherwise re-enter forever. Observed live: a stub whose counter + // advanced on every read kept a blocked agent restating the same refusal + // thirteen times before the ceiling ended it. + writeMemory(cwd, { key, count: 0, fingerprint, answer }); + allow(); + return; + } + } + + process.stdout.write(JSON.stringify({ + decision: "block", + reason: reason(input.last_assistant_message ?? "", awaiting), + })); + process.exit(0); +} + +function readState(cwd) { + const result = spawnSync("wfctl", ["brief", "--json"], { + cwd, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0 || !result.stdout) { + return undefined; + } + try { + return JSON.parse(result.stdout); + } catch { + return undefined; + } +} + +/** + * Everything the collectors observed, minus the timestamp that changes on every + * run. Counters inside signal facts — files reviewed, packets accepted, pending + * captures — move whenever work lands, so this distinguishes a turn that did + * something from a turn that only spoke. + */ +function stateFingerprint(report) { + return createHash("sha256") + .update(JSON.stringify(report.signals ?? [])) + .digest("hex"); +} + +function memoryPath(cwd) { + return join(cwd, ".workflow/current/stop-guard.json"); +} + +function readMemory(cwd) { + try { + const value = JSON.parse(readFileSync(memoryPath(cwd), "utf8")); + return { + key: typeof value.key === "string" ? value.key : "", + count: Number.isInteger(value.count) ? value.count : 0, + fingerprint: typeof value.fingerprint === "string" ? value.fingerprint : "", + answer: typeof value.answer === "string" ? value.answer : "", + }; + } catch { + return { key: "", count: 0, fingerprint: "", answer: "" }; + } +} + +function writeMemory(cwd, value) { + try { + const path = memoryPath(cwd); + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value)}\n`, "utf8"); + renameSync(temporary, path); + return true; + } catch { + return false; + } +} + +function reason(message, awaiting) { + const tail = message.length > MESSAGE_LIMIT + ? `…${message.slice(-MESSAGE_LIMIT)}` + : message; + const outstanding = awaiting + .map((signal) => ` - ${signal.summary}${signal.subject ? ` (${signal.subject})` : ""}`) + .join("\n"); + return [ + "Automatic turn check from wfctl. The maintainer did not write this.", + "", + "The turn ended with this text:", + tail, + "", + "The repository reports work awaiting the agent:", + outstanding, + "", + "If that text stated a next action that was not taken, take it now.", + "Continue while there is work you can do without the maintainer; this check", + "keeps returning as long as each turn moves the repository, and releases on", + "the first turn that does not.", + "", + "If the outstanding work genuinely needs the maintainer, say what you need", + "from them in one line and end. Do not acknowledge this check, agree with", + "it, explain yourself, or answer with an empty turn.", + ].join("\n"); +} + +main(); diff --git a/.workflow/runtime/idle-guard.sh b/.workflow/runtime/idle-guard.sh new file mode 100644 index 0000000..d5869a2 --- /dev/null +++ b/.workflow/runtime/idle-guard.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Report a background command that has gone silent. Never decide for the agent. +# +# Duration is the wrong test: a talkative hour-long build is healthy and a +# silent loop is not. Silence only means nobody can tell from out here, so this +# reports and exits while the child keeps running. Exiting is the point — a +# finished background task is the only channel that reaches a working agent. +# +# The child writes to a regular file, never to a pipe. A stalled writer on a +# pipe blocks whoever reads it, which is how an earlier version of this script +# hung in exactly the way it exists to catch. + +set -u +IDLE="${IDLE:-600}" +SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") +POLL="${IDLE_GUARD_POLL:-2}" + +usage() { + echo "usage: idle-guard.sh --shell <command-string>" >&2 + echo " idle-guard.sh --watch <pid> --log <path>" >&2 + exit 64 +} + +# Progress is the two streams together; they stay separate everywhere else, +# because merging them would change what the caller sees on a healthy run. +log_size() { + local total=0 file + for file in "$@"; do + total=$(( total + $(wc -c <"$file" 2>/dev/null | tr -d ' ' || echo 0) )) + done + printf '%s' "$total" +} + +# Consumed CPU in seconds. A process that prints a heartbeat while blocked looks +# healthy to a silence test and stalled to this one. +cpu_seconds() { + local raw + raw=$(ps -o time= -p "$1" 2>/dev/null | tr -d ' ') || return 1 + [ -n "$raw" ] || return 1 + printf '%s' "$raw" | awk -F: '{s=0; for(i=1;i<=NF;i++) s=s*60+$i; printf "%d", s}' +} + +# Fast commands must not pay for the watch, long ones must not spin: start +# tight, relax once it is clear this is not a quick command. +poll_interval() { + local ran="$1" + [ -n "$POLL" ] && { printf '%s' "$POLL"; return; } + if [ "$ran" -lt 5 ]; then printf '0.1' + elif [ "$ran" -lt 30 ]; then printf '1' + else printf '5' + fi +} + +report() { + local pid="$1" started="$2" log="$3" command="$4" trigger="$5" + local err="${log}.err" + local elapsed=$(( $(date +%s) - started )) + { + echo "idle-guard: ${trigger} — this is a prompt to check, not a verdict." + echo " command : ${command}" + echo " pid : ${pid} still running, untouched" + echo " elapsed : ${elapsed}s" + ps -o time=,%cpu=,state= -p "$pid" 2>/dev/null \ + | sed 's/^ *//' | sed 's/^/ cpu,%,state: /' + echo " full log : ${log} (stderr: ${err})" + echo " last output:" + tail -n 5 "$log" "$err" 2>/dev/null | grep -v '^==>' | sed 's/^/ | /' + echo + echo " Silence is not evidence of failure. Do NOT kill or restart on this" + echo " report alone, and do NOT agree that something is wrong because it was" + echo " reported. Establish it: compare cpu time against elapsed, then check" + echo " the data the job writes. Never check by matching a process name — a" + echo " pattern matches the checking shell too, which is how silent waiting" + echo " gets mistaken for progress. Restarting healthy work is the more" + echo " expensive mistake." + echo + echo " re-arm : IDLE=${IDLE} bash \"${SELF}\" --watch ${pid} --log ${log} &" + echo " abandon: kill -KILL ${pid}" + } >&2 +} + +watch_pid() { + local pid="$1" log="$2" command="$3" started="$4" + local last size cpu_last cpu_at now quiet stalled + last=$(date +%s); cpu_at=$last + size=$(log_size "$log" "${log}.err"); cpu_last=$(cpu_seconds "$pid" || echo 0) + while kill -0 "$pid" 2>/dev/null; do + sleep "$(poll_interval $(( $(date +%s) - started )))" + now=$(log_size "$log" "${log}.err") + if [ "$now" != "$size" ]; then size="$now"; last=$(date +%s); fi + stalled=$(cpu_seconds "$pid" || echo "$cpu_last") + if [ "$stalled" != "$cpu_last" ]; then cpu_last="$stalled"; cpu_at=$(date +%s); fi + + quiet=$(( $(date +%s) - last )) + if [ "$quiet" -ge "$IDLE" ]; then + report "$pid" "$started" "$log" "$command" "no output for ${quiet}s" + return 125 + fi + # A CPU stall was tried as a second trigger and dropped: I/O-bound work — + # downloads, network waits, a shell loop around sleep — consumes almost no + # CPU while progressing perfectly well, so it fired on healthy commands. + # Consumed CPU stays in the report, where it tells the agent whether a + # silent process is waiting or working. It is evidence, not a verdict. + done + return 0 +} + +case "${1:-}" in +--shell) + [ $# -eq 2 ] || usage + command="$2" + log="${TMPDIR:-/tmp}/idle-guard.$$.log" + : >"$log" + : >"${log}.err" + # Bash hands a background job /dev/null unless stdin is redirected explicitly, + # which would silently starve any command that reads it. + if command -v stdbuf >/dev/null 2>&1; then + stdbuf -oL -eL bash -c "$command" >"$log" 2>"${log}.err" <&0 & + else + bash -c "$command" >"$log" 2>"${log}.err" <&0 & + fi + child=$! + tail -n +1 -f "$log" 2>/dev/null & + streamer=$! + tail -n +1 -f "${log}.err" >&2 2>/dev/null & + streamer_err=$! + disown "$streamer_err" 2>/dev/null || true + # Drop it from the job table so terminating it does not print job-control + # noise into the output the agent reads. + disown "$streamer" 2>/dev/null || true + started=$(date +%s) + watch_pid "$child" "$log" "$command" "$started" + stalled=$? + kill "$streamer" "$streamer_err" 2>/dev/null + if [ "$stalled" -eq 125 ]; then + # Leave the child and its log in place: the agent decides, and --watch + # re-arms onto the same pair without losing a second of work. + exit 125 + fi + wait "$child" + code=$? + rm -f "$log" "${log}.err" + exit "$code" + ;; +--watch) + [ $# -eq 4 ] && [ "$3" = "--log" ] || usage + pid="$2" + log="$4" + started=$(date +%s) + watch_pid "$pid" "$log" "(re-armed watch on pid $pid)" "$started" + stalled=$? + [ "$stalled" -eq 125 ] && exit 125 + echo "idle-guard: pid ${pid} finished; its output is in ${log}" >&2 + exit 0 + ;; +*) + usage + ;; +esac diff --git a/.workflow/state.json b/.workflow/state.json new file mode 100644 index 0000000..fc8ff6a --- /dev/null +++ b/.workflow/state.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "installedVersion": "0.8.0", + "profile": "leaf", + "files": { + ".claude/rules/evidence-first.md": { + "sha256": "4adf35cd60692e32728b5de325fc40637cee634a8b013ff434650ebe5b78c07c" + }, + ".claude/rules/execution-continuity.md": { + "sha256": "746dc5804137757f058e2e04d097fcad7c4a2277eb2f073045b9ab5e14917842" + }, + ".claude/rules/maintainer-review.md": { + "sha256": "429a3a7556703b51f06c36f81ef6fa96c147b80e3f616f086f9904c39a2d7499" + }, + ".claude/rules/project-work-bundles.md": { + "sha256": "ea5088c79442f080c0f03a4fffd46672806e869dec6433757d8f8641254da680" + }, + ".claude/rules/project-work-lifecycle.md": { + "sha256": "ac5212a9f8453175007b5bdb0b587e6aca47648d6273ae2cdb0b014d9365ea14" + }, + ".claude/rules/workflow-routing.md": { + "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" + }, + ".workflow/.gitignore": { + "sha256": "61bff3ecb1270f462919509a371a296d1dc155a9e7306931191cdb8584053532" + }, + ".workflow/rules/evidence-first.md": { + "sha256": "4adf35cd60692e32728b5de325fc40637cee634a8b013ff434650ebe5b78c07c" + }, + ".workflow/rules/execution-continuity.md": { + "sha256": "746dc5804137757f058e2e04d097fcad7c4a2277eb2f073045b9ab5e14917842" + }, + ".workflow/rules/maintainer-review.md": { + "sha256": "429a3a7556703b51f06c36f81ef6fa96c147b80e3f616f086f9904c39a2d7499" + }, + ".workflow/rules/project-work-bundles.md": { + "sha256": "ea5088c79442f080c0f03a4fffd46672806e869dec6433757d8f8641254da680" + }, + ".workflow/rules/project-work-lifecycle.md": { + "sha256": "ac5212a9f8453175007b5bdb0b587e6aca47648d6273ae2cdb0b014d9365ea14" + }, + ".workflow/rules/workflow-routing.md": { + "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" + }, + ".workflow/runtime/guard-background-bash.mjs": { + "sha256": "0bb95f5bc34cb2ee9c3c453a171cd36f60d8c7d4eeb283ea07e8ad91d5b3ef93" + }, + ".workflow/runtime/guard-stop.mjs": { + "sha256": "118f46237d670e61cacf7aa44d50d8ce8d43acdab26dc52b6dfefdf7f257d27f" + }, + ".workflow/runtime/idle-guard.sh": { + "sha256": "8a132664ff43cc732e4da14b33e7b72f89e5facbb26169b3135eac2c64390da2" + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c30350a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,133 @@ +<!-- wfctl:begin --> +## Project workflow + +This block is managed by `wfctl`. Read `.workflow/config.json` and all files under `.workflow/rules/` before project work. Use `PROJECT_WORKFLOW.md` as the maintainer-facing contract for review gates. + +- Invoke `analyze-with-graphify` before inspecting, searching, planning, + changing, debugging, reviewing, or verifying source code, even when the + maintainer does not mention Graphify. +- Require that skill to confirm and invoke the official native `graphify` skill + exposed in the current session before project analysis continues. +- Treat Graphify as the primary source-code navigation tool; text search is + supplementary and direct source inspection is authoritative. +- Do not use Graphify as the primary analyzer for Markdown intake or curated + knowledge. +- Use QMD from the knowledge repository for Markdown retrieval. Treat its + index, ranking, and snippets as navigation only; verify by direct reading and + authoritative sources. +- Present bounded review packets and require explicit maintainer decisions at the gates defined by the workflow. +- Record framing and completion approvals with `wfctl work approve`, never by + editing `maintainer_review`. The maintainer confirms in their own terminal; + a hand-written approval receipt fails verification. +- Ask one material question at a time, include a recommendation, and update + the durable record before continuing. +- Preserve uncertainty and report missing evidence instead of guessing. +- Execute required `wfctl` commands yourself when tool access permits. Do not + delegate routine CLI operation, spec editing, or record maintenance to the + maintainer; ask them for decisions, approval, or missing authority. +- Treat the maintainer's natural-language request as the user interface. + Outside bootstrap or explicit troubleshooting, never require them to know a + subcommand, record ID, generated path, QMD query, Graphify invocation, or + structured-file schema. Resolve those mechanics yourself. +- When internal state offers one safe valid continuation, announce it and + continue in the same turn. Ending a turn on a stated next action is the + announcement without the continuation: take the action, or record why it + cannot be taken. A written report is progress and never the finish line; + completion is the terminal status of the required records. This holds while + executing accepted work and not while shaping or specifying, where asking is + the work. When several materially different choices remain, present their + human meaning, evidence, and recommendation; after the maintainer chooses, + execute the corresponding commands yourself. +- For significant multi-turn work, create the central bundle early. After every + material maintainer turn or agent investigation cycle, preserve + consequential new understanding in the owning record's broad `Discovery + ledger`, update the affected semantic state, and refresh its structured + checkpoint last. The preservation trigger is consequence of information + loss, not a fixed category of findings. +- Run `wfctl brief --json` before anything else in a session, unless a session + brief was already delivered as context, in which case use that one. It is the + authoritative current state of this repository: signals are observed facts and + capabilities are derived from them. Do not rediscover that state by scanning + records, and do not read the list back to the maintainer. Compose one short + orientation from it — what exists, what is in progress, what waits on them — + and offer the operations reported available. For a blocked capability, name + what would unblock it instead of starting it. The brief never starts work; a + signal with `awaits: maintainer` is a question for them, not a task for you. +- On resume, compaction, or a clean-session start, run `wfctl work context + --stage resume` without an ID. Auto-select only when exactly one active record + is bound to the current checkout. If several exist, inspect `wfctl work + status` and ask the maintainer which human outcome to resume; never guess. + Read every required file and discovery entry completely, then recover from + the bundle, current checkpoint, and exact claim rather than conversation + memory. +- Use `changes/inbox/` only for pending captures that have no active or curated + owner. Resolve each capture to existing destinations or discard it with a + reason; never duplicate active progress there. +- Do not create a competing leaf-local spec or issue tracker. Claim one central + frontier issue from the exact bound checkout before implementation. Before + completion, account for every bundle file at its current hash; a receipt + proves accounting, not comprehension. +- Treat the maintainer/product and engineering roads as linked, first-class + views of the same project, never one blended document and never one derived + from the other. Product pages explain current behavior to stakeholders; + engineering pages explain implementation to engineers and operators. + Decision lineage connects both roads rather than forming a third flat view. +- Route broad project discovery, newcomer onboarding, Area exploration, and + focused product-understanding questions to `explore-project-knowledge`. + Exploration is read-only: answer progressively from curated knowledge + without requiring the user to know Areas, capability names, or file paths. +- Route product authoring to `curate-product-knowledge`, technical authoring to + `curate-engineering-knowledge`, and every material knowledge edit through + `verify-knowledge-quality` before it becomes stable. Keep authority/truth + and reader communication as separate semantic passes. + +This is a leaf repository. Its project knowledge is located at `../knowledge-humid`. + +Classify work with the installed `manage-project-work` skill before changing +implementation state. For significant work, create the central bundle first, +then invoke `analyze-with-graphify` and `align-project-knowledge` before +implementation. + +If a consequential initiative has several unresolved dependent product or +architecture choices and cannot yet support honest acceptance criteria, +recommend `shape-project-direction`. Start its Wayfinder map only after +maintainer agreement, and do not edit code until the map has been synthesized +into a reviewed bounded specification. + +For read-only questions about what the project is, what it currently provides, +or how one product direction works, invoke `explore-project-knowledge` against +the configured knowledge repository. Do not require the user to name an Area, +capability, or knowledge path. A product explanation alone does not create a +work record or authorize code changes. + +Run QMD from the configured knowledge repository for knowledge retrieval. Do +not query `raw/` or `intake/` to fill gaps in current project truth. + +After `wfctl work start`, run `wfctl work status <id>` and the stage-specific +`wfctl work context <id>` before any code edit, after changing directories, +and before verification or close. On a clean session or unspecified resume, +start with `wfctl work context --stage resume` without an ID; it may select only +one bound active record and otherwise requires a maintainer choice. +Use only the reported `Code roots` for their respective code operations and +the reported `Spec` only for record updates. Refresh the owning change or issue +checkpoint after every material edit. A worktree is a distinct +code root; never infer another checkout from repository name, branch, or spec +location. A branch/worktree mismatch requires explicit `wfctl work rebind` +before any code edit. + +Keep one canonical change bundle in the knowledge repository. `change.md` owns +the parent contract, optional `map.md` owns Wayfinder lineage, and `issues/` +owns bounded progress. After material discussion or investigation, preserve +consequential new understanding in the current owner's `Discovery ledger`, +update its semantic state, and refresh that record's checkpoint last. Claim one +frontier issue from the exact leaf before implementation. Verify the whole +bundle with `verify-project-work`, +promote durable truth separately, then archive the directory intact. + +During promotion, keep linked product and engineering views separate. Invoke +`curate-product-knowledge` for stakeholder-facing behavior, +`curate-engineering-knowledge` for technical realization, and +`verify-knowledge-quality` before a materially changed concept becomes stable. +The quality gate keeps authority/truth and reader communication as independent +passes. +<!-- wfctl:end --> diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/PROJECT_WORKFLOW.md b/PROJECT_WORKFLOW.md new file mode 100644 index 0000000..0dcd5cf --- /dev/null +++ b/PROJECT_WORKFLOW.md @@ -0,0 +1,384 @@ +<!-- wfctl:begin --> +# Project Workflow + +This is the maintainer-facing operating guide installed by `wfctl`. + +- Profile: `leaf` +- Project knowledge: `../knowledge-humid` + +The workflow is a project partnership system. It keeps the maintainer and agent +inside one shared project model from product intent to verified delivery. The +maintainer can recover and navigate that model directly; the agent uses the +same model to investigate, implement, verify, and maintain continuity. + +It provides two linked, first-class roads: + +- the **maintainer/product road** explains purpose, capabilities, behavior, + rules, delivery, and evolution in human language; +- the **engineering road** explains architecture, ownership, source + realization, contracts, operations, and verification. + +Neither road is derived from the other. Shared Areas, changes, and decision +history keep them aligned. The agent inventories and verifies evidence, +maintains records, and presents bounded decisions. The maintainer supplies +product intent, resolves authority conflicts, and approves material +commitments. + +## What the maintainer operates + +Your normal interface is conversation. Describe the outcome in project +language; the agent chooses and runs `wfctl`, QMD, Graphify, Git inspection, +and record-maintenance operations. You do not need command syntax, record IDs, +generated paths, or structured-file schemas. + +The normal optional manual CLI entry points are `wfctl init knowledge`, `wfctl +init leaf`, and `wfctl upgrade` from the repository being upgraded; the setup +agent can run them too. `--target` and other commands remain available for +agents, automation, diagnostics, recovery, and workflow contributors. You +review framing, missing authority, material choices, completion, and current +knowledge claims. + +## Installed skill lineage + +The leaf delivery flow directly reuses and modifies selected MIT-licensed +skills from `mattpocock/skills`: Wayfinder, To Spec, To Tickets, Implement, +TDD, and Code Review. `wfctl` integrates them into its own central bundle, +worktree claims, knowledge alignment, and completion gates; it does not install +the original suite or a second tracker beside them. + +Project-scope copies live under `.agents/skills/` and/or `.claude/skills/` and +are recorded by `skills-lock.json`. These are generated consumer copies: update +the canonical `wfctl` package, then run `wfctl upgrade` here instead of editing +them locally. The canonical distribution keeps one third-party provenance +record and one upstream license instead of repeating them in every skill. +User-scope locations follow the selected agent convention. + +After initialization, ask in ordinary language: “help me understand this +project,” “process raw,” “reconstruct the baseline,” “explain this Area,” +“shape this broad direction,” “research this external constraint,” “implement +this change,” or “check the workflow.” The installed skills translate those +requests into the complete internal procedure. You do not need to invoke a +skill by name. + +## Trust boundary + +| Surface | Purpose | Trust | +| --- | --- | --- | +| `raw/` | Continuous append-only dumps and source material | Untrusted clue source; never evidence | +| `intake/` | Git-frozen raw review cases | Operational audit trail; never cited by knowledge | +| `reconstruction/` | Source-first project baselines and audits | Qualified review records; opt-in, not default truth | +| `changes/active/` | One change bundle per significant outcome: parent contract, structured checkpoints, optional map, bounded issues, artifacts, and review ledger | Current execution agreement | +| `changes/archive/` | Closed change bundles plus resolved capture receipts | Historical record qualified by outcome and reviews | +| `changes/inbox/` | Pending captures with no active or curated owner yet | Non-authoritative queue awaiting explicit routing or discard | +| `knowledge/` | Curated OKF concepts | Default current project knowledge | +| source repositories | Executable implementation | Implementation authority at an exact revision | + +Raw text can tell the agent what to investigate. It cannot support a claim, +even when several raw files agree. A trusted derivative must cite the +maintainer decision, pinned code, runtime receipt, reviewed archived change, or +primary external source that independently established the claim. + +## Multiple inputs, one promotion gate + +Raw dumps, source reconstruction, and ongoing work stay separate until +verification: + +1. A bounded `raw/` scope is frozen to exact Git blobs in `intake/`. + QMD helps locate relationships; the agent then reads every frozen file and + extracts candidate claims. +2. A bounded reconstruction binds exact clean leaf revisions, uses Graphify + plus direct source, creates repository dossiers, and separates observed + implementation from accepted intent. +3. Significant ongoing work produces a central bundle under `changes/active/` + with stable acceptance, bounded issue progress, complete file accounting, + and fresh implementation receipts. +4. Every lane verifies each claim against its proper authority. +5. The maintainer adjudicates intent, normative decisions, and unresolved + conflicts. +6. Only then does the agent update `knowledge/` and run the strict validator. + +Unresolved raw candidates remain in intake. `knowledge/uncertainties/` is only +for live questions supported by trusted current evidence. + +## Graphify boundary + +Graphify is mandatory for source-code navigation and relationship analysis. +The routing skill checks that the official native `graphify` skill is active, +invokes any more specific Graphify skills, and stops code work if they are +missing. The agent then directly inspects the actual source and checks at the +bound Git revision; Graphify output itself is not authority. + +Graphify is not the analyzer for Markdown, raw intake, or OKF concepts. QMD +provides BM25, semantic, and hybrid retrieval for those surfaces; direct file +reading, Git coverage, provenance, and validation remain authoritative. + +## Compiled knowledge and claim graphs + +`wfctl knowledge build` validates `knowledge/` and compiles only authored +Markdown links, typed `x-wf.relations`, Area ownership, and decision lineage +into `.workflow/current/knowledge-graph.json`. The ignored file is a +rebuildable navigation artifact: it adds no inferred truth and is never edited +or cited as authority. + +The same build writes `.workflow/current/claim-ledger.json` from intake and +reconstruction cases. It contains atomic claim states and only explicit +supersession, contradiction, refinement, implementation, and derivation +relations. It helps trace adjudication and chronology, but never decides truth +or compensates for a relation the agent failed to record. + +QMD discovers candidate documents by meaning. The compiled graph expands those +candidates through explicit reviewed relationships. The claim ledger traces +operational lineage. Graphify handles source code. In every case, the agent +reads the selected source documents directly. + +## QMD retrieval boundary + +`wfctl` installs a project-local `.qmd/index.yml` in the knowledge repository. +Its collections are intentionally separated: + +- `knowledge` is the only default search surface; +- `changes`, `intake`, `reconstruction`, and `raw` require explicit collection + selection. + +The QMD index is disposable. Search rank and snippets help navigation but prove +neither corpus coverage nor truth. The agent runs QMD from the knowledge root, +updates the index after content changes, and reads selected files directly. +The official native `qmd` skill must be active in the current agent session; +installation on disk requires a session restart before it counts. + +`wfctl check` distinguishes two readiness levels: + +- BM25 lexical readiness is required and depends on a healthy project-local + index refreshed by `qmd update`; +- semantic/hybrid readiness depends on local models and current embeddings. + +Missing models or embeddings are warnings while BM25 remains healthy. Model +download and embedding work happen only when semantic retrieval is needed. + +## OKF and the stricter workflow profile + +`knowledge/` follows +[Open Knowledge Format v0.2](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md). +OKF is a portable Markdown format, not an approval workflow. This project adds +a stricter profile: + +- explicit `status`, `generated`, provenance, and current verification; +- explicit `view`, `purpose`, and `audience` so product and engineering + knowledge cannot silently collapse into one document; +- explicit authority classes so deterministic validation can distinguish + normative, implementation, historical, and external claims; +- explicit intent, delivery, and alignment state for product-bearing concepts; +- claim-level source IDs joined to Markdown footnotes; +- pinned repository revision and path for code sources; +- human verification for intent and normative decisions; +- explicit supersession or deprecation reason; +- reciprocal acyclic decision lineages with one stable current record; +- a semantic quality receipt with independent authority/truth and + reader-communication axes bound to the current content hash; +- stakeholder sections and a no-code boundary for product documents; +- technical coverage and linked product meaning for engineering documents; +- no raw path, source, link, or footnote in current knowledge. + +`stable` is lifecycle, not automatic truth. A material edit updates +`generated.at` and changes the deterministic knowledge content hash. Older +verification and quality receipts no longer match. The agent runs the semantic +quality reviews and records fresh axis plus verification receipts only after computing +`wfctl knowledge hash --concept <path>`. + +## Choose the work route + +Use the full workflow when work may change observable behavior, domain meaning, +interfaces, schemas, protocols, data or control flow, persistent state, +security, reliability, operations, architecture, ownership, or coordination +across components or repositories. + +Use the lightweight path only when behavior and contracts clearly remain +unchanged. Size is not the classifier. When ambiguous, the agent explains the +impact, recommends a route, and asks you. A pending capture may preserve a +useful lightweight result without imposing the full gate, but only when no +active change or curated concept already owns it. + +Accepted unowned material enters `changes/inbox/` through `wfctl work capture +add`. Leaf captures retain exact source/worktree metadata; knowledge captures +retain project-only intake/reconstruction proposals and their claim references. +The knowledge agent lists and reads pending captures, creates real destinations, +then resolves each as routed or discarded. Resolved receipts move to +`changes/archive/captures/`; they never become authority by themselves. + +Active work never uses the inbox for session state. The owning change or issue +contains one structured checkpoint. The agent refreshes it after material +edits, and `wfctl work context` rejects a stale hash while still requiring the +underlying files to be read completely. + +Read-only explanation, history, ownership, and health questions run +automatically. Raw processing, whole-project reconstruction, durable external +research, semantic curation, and broad direction shaping are deliberate: +unless you already requested the outcome, the agent explains the gap and asks +one focused confirmation before starting. + +For a consequential initiative whose route is too unclear for one honest +specification session, the agent may recommend deliberate Wayfinder. One map +stores the destination, standing context, fog, and named resolution pointers; +precise questions become dependency-aware issues. When the route clears, the +agent reads every issue, synthesizes the ordinary change specification, and +only then starts delivery. It never builds the destination from an unresolved +map or creates a parallel strategy source. + +## Review gates + +1. **Routing** — only when significance is ambiguous. +2. **Framing** — outcome, scope, exclusions, acceptance criteria, and new + decisions before significant implementation. Clear existing instructions + can satisfy this; material re-scoping reopens it. +3. **Authority** — whenever evidence cannot establish current intent, + chronology, or which source governs. +4. **Knowledge** — material claims about vision, product meaning, architecture, + ownership, contracts, policy, decisions, supersession, or accepted risk. +5. **Completion** — acceptance results, directly inspected implementation, + fresh checks, deviations, risks, and the knowledge delta or no-update reason. + +An approval is explicit. Silence and continued conversation are not approval. +You never edit YAML manually. + +Framing and completion approvals for significant work are recorded by you, with +`wfctl work approve <change-id> --stage framing|completion --by +human:<your-id>`. The command prints the decision and waits for you to type +`approve`; the agent can prepare it but cannot answer that prompt, and a +receipt written into the record by hand fails verification. Automation may +substitute `--token` matching a `WFCTL_APPROVAL_TOKEN` you set out of band. + +This records provenance, not identity. It shows the approval came from a +deliberate separate step, not that a specific person typed it. Every other +review decision the agent records as a stable `human:<reviewer-id>` and +timestamp after you answer. + +## Review packet + +Each request should contain: + +1. **Decision** — the exact claim, framing, or outcome. +2. **Evidence** — pinned sources and fresh verification. +3. **Conflicts** — contrary evidence, gaps, deviations, or risk. +4. **Recommendation** — the agent's preferred answer and reasoning. +5. **Requested response** — approve, correct, or defer. + +Deferral is valid. The agent preserves uncertainty instead of guessing. +When several decisions are needed, the agent asks one at a time and updates +the living record before continuing. + +## Significant-work loop + +1. Classify the task. +2. Immediately create and bind a central bundle with `wfctl work start`. +3. Use `wfctl work status` to distinguish every exact implementation `Code + root` from the central bundle. Use stage-specific `wfctl work context` to + enumerate every file the agent must read. Project-only work has no code root. +4. Record the current request, constraints, open questions, and next action. +5. Analyze source code through Graphify and direct inspection in every bound + repository; skip this only when the record has no code scope. +6. Align with current `knowledge/`. +7. Resolve blocking authority questions and obtain framing approval. +8. Set the change active. For multi-session work, create dependency-aware + issues whose acceptance coverage and repository scope are explicit. +9. Read and claim one frontier issue from the exact bound leaf. Implement only + there while keeping issue progress and the parent contract current. +10. Reconcile every stable criterion against the actual implementation. +11. With normal maintainer authorization, preserve the implementation + in the bound Git commit; `wfctl` never commits automatically. +12. Run final checks against every clean commit and record one revision and + worktree receipt per repository. +13. Enumerate and read the complete bundle, refresh every content-hash receipt, + and reject unseen, changed, malformed, or silently dropped work. +14. Promote durable verified truth into `knowledge/`, or record why no current + knowledge changed. +15. Obtain completion approval, mark the record completion-ready, and compute + current content hashes for promoted stable concepts. +16. Run `wfctl knowledge validate --target <Knowledge root>` for promoted + concepts. +17. Run `wfctl knowledge build --target <Knowledge root>` to prove links, + authored relationships, and stable-concept reachability. +18. Run `wfctl work verify`, and archive the honest + outcome with `wfctl work close`. + +A material turn may come from discussion or investigation. It changes a +requirement, constraint, alternative, decision, scope, evidence, risk, +question, next action, or consequential understanding. The agent preserves new +information in the owning `Discovery ledger` when losing it could cause +repeated material investigation, a different choice, misunderstanding, or +unsafe action; then it updates mutable state and any decision lineage. After +interruption, compaction, or a clean-session start, it runs `wfctl work context +--stage resume` without an ID, accepts automatic selection only for one bound +record, reads every required bundle file completely, and resumes from recorded +state instead of chat memory. + +Partial or abandoned outcomes are valid historical records. They must never be +relabeled as completed. A completed close also requires a clean bound checkout, +so the archived revision actually contains the verified implementation; the +workflow never commits automatically. + +## Routine health + +Ask the agent to “check the workflow environment” or “upgrade the workflow.” +It runs diagnostics or previews the upgrade, explains conflicts in human +terms, and requests only the decisions needed. Generated assets with local +edits become explicit conflicts and are never silently overwritten. + +## Leaf repository practice + +The curated project knowledge for this repository is at +`../knowledge-humid`. + +At the start of significant work, the agent first creates a central bundle so +the discussion cannot disappear after compaction. It then performs Graphify +code analysis and QMD-assisted current-knowledge alignment before presenting +the framing packet. QMD runs from the configured knowledge root and searches +only its `knowledge` collection by default. At the end, review the verification +and knowledge delta before accepting completion. + +Describe the desired change in ordinary language. The agent owns creation, +status checks, verification, and archival of the work record; you never need +its ID or commands. It asks you only for ambiguous routing, framing, product +authority, commit authorization, and completion decisions. + +You may also ask read-only product questions here: + +> I am new to this project. What is it for and what can it do today? + +The agent reads the configured knowledge repository and progressively explains +the product without creating a work record or changing this checkout. + +The canonical bundle remains under `changes/active/<change-id>/` in the +knowledge repository. `change.md` holds the parent contract, optional `map.md` +holds Wayfinder lineage, and `issues/` holds bounded work. Each active owner has +one structured checkpoint. This leaf stores +only ignored binding and claim pointers in `.workflow/current/`. + +`wfctl work status` reports intentionally different paths: + +- `Code roots`: one or more exact leaf checkouts or linked worktrees where + their respective implementation may be read and modified. +- `Spec`: the parent `change.md`; stage-specific `wfctl work context` lists + every additional map, issue, blocker, or artifact the agent must read. + +The agent must never infer another checkout from repository name, branch, Git +common directory, or spec location. A worktree or branch mismatch blocks +verification and close until an explicit `wfctl work rebind`. + +During discussion or investigation, every material change and every newly +learned fact whose loss could cause repeated work, a different decision, +misunderstanding, or unsafe action is written to the owning bundle record. The +record's `Discovery ledger` accepts any consequential observation with its +basis, implication, scope, and destination; it is not restricted to named +categories. The checkpoint is refreshed last. On resume, the agent discovers +the one active binding when unambiguous, inspects its checkpoint, and reads +every file and discovery entry listed by the current context rather than +relying on remembered chat. + +When the completed change updates durable knowledge, review two separate +results when both changed: the stakeholder-facing product behavior and the +engineering realization. The product view should be understandable without +code; the engineering view should pin the actual implementation. A semantic +quality receipt with independent authority/truth and reader-communication +passes, plus strict structural validation, must pass before either is called +stable. +<!-- wfctl:end --> diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..0d8cd56 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "skills": { + "align-project-knowledge": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "c1b061e5bc416f8afc1f9265e67196a9e9b0de767e1957d383714119933bb846" + }, + "analyze-with-graphify": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "f63348d1beb0145770f1436fb7299ef01d3702ef8f7f43b78c120f2bf4ac7297" + }, + "curate-engineering-knowledge": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "c7e0c90d212a52c82b8d974d7553a8a73517146d4ea7454749c5274758c05e9e" + }, + "curate-product-knowledge": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "ce5c45e2449918252b18893c948ea6ed89267e7b3bd62dca2a86dfb99742e025" + }, + "curate-project-knowledge": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "26574b9003576326d9787a98a55db2c92409ddc516bd6327df0eab5a63bba08b" + }, + "explore-project-knowledge": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "60b8753a3c9e0cb30550313dde39530d41cac669da6894cf2a32fa2fb23b9760" + }, + "implement-work-item": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "ad5d30eb8c656ad1162116f1cf7318211c973ff694159081a121374432b782d2" + }, + "manage-project-work": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "d3bb7dfc51433c2d6f5a9b7d0f375fae6ec3a7253b47473e59b3fd55b4131327" + }, + "qmd": { + "source": "/Users/lukachisama/.bun/install/global/node_modules/@tobilu/qmd/skills/qmd", + "sourceType": "local", + "computedHash": "c21e27d9d9de60158103055495ed0075eeb295df6c5bc2d8e795b948173cb6a8" + }, + "setup-workflow-environment": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "746ab468dd038e38a0b4e8d53cda2e2502b9997695e9833d60b93e3e8ca60d7f" + }, + "shape-project-direction": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "7014dda9496a2b403aa027863311f0e1a7c6a038e3d4498d4ec61dc82035ead7" + }, + "specify-project-change": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "274b797fc56704ac5f6c83bef60a438f3edbafa081806d1c0608ecc37b2ce5af" + }, + "split-project-change": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "06d265d98ad7a9e9383819d98c479d09f2141f67d5720ef9b1800622796fe811" + }, + "verify-knowledge-quality": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "3ba1e87f02ccfdb7ef5fc3be0438d37c3e4063780bd85fe86537747987740431" + }, + "verify-project-work": { + "source": "/Users/lukachisama/Documents/agent-skills/workflow", + "sourceType": "local", + "computedHash": "8772c9dcc74bc926ac47bf9aa6933f29cb74e2b94cc7c1a4ceecd7f7a27cf383" + } + } +} From 81a14158195d8f5984fc0c88f3c20ca269969be4 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 02:25:02 +0300 Subject: [PATCH 002/124] build: vendor the smplx SDK fork as a submodule Adds lukachi/smplx at the repository root, following the convention the lwk submodule already sets: fork to the maintainer's account, pin a named working branch, vendor at the root rather than under packages/. The pinned branch humid/wasm-wallet-integration is local until the slice that finishes this work. Until it is pushed, `git submodule update` on this branch cannot resolve the gitlink for anyone who does not already hold both checkouts. --- .gitmodules | 4 ++++ smplx | 1 + 2 files changed, 5 insertions(+) create mode 160000 smplx diff --git a/.gitmodules b/.gitmodules index e257c04..9e21e84 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,7 @@ path = lwk url = https://github.com/lukachi/lwk.git branch = humid/esplora-backend-config +[submodule "smplx"] + path = smplx + url = https://github.com/lukachi/smplx.git + branch = humid/wasm-wallet-integration diff --git a/smplx b/smplx new file mode 160000 index 0000000..9942b21 --- /dev/null +++ b/smplx @@ -0,0 +1 @@ +Subproject commit 9942b21f90c0bc1096970de03a164e92d0c2b376 From f3483e303a1a42b7cc40ed494fae7a80607ee744 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 02:25:10 +0300 Subject: [PATCH 003/124] chore: exclude the smplx submodule and wfctl-managed files from format and lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smplx submodule joins lwk in both ignore lists. A vendored fork must not be reformatted or linted by this repository: doing so rewrites files the fork owns and makes its diff unreadable. The wfctl-managed surface is excluded from formatting for the same reason it already excluded AGENTS.md and CLAUDE.md — those files are generated and synced by wfctl, so any formatting applied to them is undone on the next sync. Without this, twenty-six generated files failed `format:check` and blocked every commit in the repository. --- .oxfmtrc.json | 6 ++++++ .oxlintrc.json | 1 + 2 files changed, 7 insertions(+) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b61b8c1..b6f7581 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,8 +10,14 @@ "sortTailwindcss": true, "ignorePatterns": [ "lwk/**", + "smplx/**", "AGENTS.md", "CLAUDE.md", + "PROJECT_WORKFLOW.md", + "skills-lock.json", + ".workflow/**", + ".claude/**", + ".agents/**", "README.md", "CHANGELOG.md", "dist/**", diff --git a/.oxlintrc.json b/.oxlintrc.json index be13118..7ebce94 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -37,6 +37,7 @@ }, "ignorePatterns": [ "lwk/**", + "smplx/**", "dist/**", "build/**", "node_modules/**", From ae34218191e74c67362461d92ec8b16b1a75fa36 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 02:38:02 +0300 Subject: [PATCH 004/124] build: advance the smplx submodule to the wasm-ready fork branch Points the gitlink at humid/wasm-wallet-integration, which carries the provider feature gate, a signer usable without networking, the change target and per-input derivation path a ranged-descriptor wallet needs, and the browser-loadable wasm-bindgen package. The branch is still local, so this gitlink does not resolve for anyone who does not hold both checkouts until it is pushed. --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 9942b21..b7e9acc 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 9942b21f90c0bc1096970de03a164e92d0c2b376 +Subproject commit b7e9acc0e2766e927920a223b929568feae503af From fe67ac8ccf4662aeeaa710d490859a87a3283d0a Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 02:44:34 +0300 Subject: [PATCH 005/124] test: stand up a test runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository had no way to run a test: no `test` script, no framework in devDependencies, and no test file anywhere under apps/ or packages/. Anything not provable in a browser was not provable at all. Uses Bun's built-in runner rather than adding a framework. Bun is already the pinned package manager, so this costs no new dependency and needs no config: it runs TypeScript directly and finds nothing inside the lwk and smplx submodules, which carry no JavaScript tests. `test` and `test:watch` scripts are added and `check` now ends with the suite, so a failing test fails the same gate as a type error. The first tests cover `formatTimeAgo`, whose expectations come from its own documented contract — sub-minute gaps read as "just now", nothing rounds to "0m" — rather than from reading its body. `@types/bun` and `bun-types` are dev dependencies, and `bun-test-env.d.ts` states the type reference once instead of repeating it at the top of every test file. --- apps/extension/src/bun-test-env.d.ts | 14 +++++++ apps/extension/src/helpers/formatters.test.ts | 38 +++++++++++++++++++ bun.lock | 6 +++ package.json | 6 ++- 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/bun-test-env.d.ts create mode 100644 apps/extension/src/helpers/formatters.test.ts diff --git a/apps/extension/src/bun-test-env.d.ts b/apps/extension/src/bun-test-env.d.ts new file mode 100644 index 0000000..4d36288 --- /dev/null +++ b/apps/extension/src/bun-test-env.d.ts @@ -0,0 +1,14 @@ +/// <reference types="bun-types" /> + +// Makes `bun:test` resolvable to `tsc`, which the test files import from. +// +// `@types/bun` re-exports `bun-types` and is supposed to be picked up automatically, +// but it is not under this project's configuration, so the reference is stated once +// here rather than repeated at the top of every test file — the same arrangement +// `vite-env.d.ts` already uses for Vite's ambient types. +// +// Side effect worth knowing: this also makes Bun's globals visible to application +// code, which does not run under Bun. Reach for a browser or extension API there, +// not `Bun.*`. + +export {}; diff --git a/apps/extension/src/helpers/formatters.test.ts b/apps/extension/src/helpers/formatters.test.ts new file mode 100644 index 0000000..33c9bce --- /dev/null +++ b/apps/extension/src/helpers/formatters.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { formatTimeAgo } from "./formatters"; + +// Expectations are taken from the documented contract of each function, not from +// reading its body: `formatTimeAgo` states that sub-minute gaps read as "just now" +// and that anything in between rounds down but never to "0m". +describe("formatTimeAgo", () => { + const now = 1_700_000_000_000; + const ago = (seconds: number) => formatTimeAgo(now - seconds * 1000, now); + + test("reads a sub-minute gap as 'just now'", () => { + expect(ago(0)).toBe("just now"); + expect(ago(44)).toBe("just now"); + }); + + test("never rounds down to '0m'", () => { + expect(ago(45)).toBe("1m ago"); + expect(ago(59)).toBe("1m ago"); + }); + + test("rounds down within each unit", () => { + expect(ago(60)).toBe("1m ago"); + expect(ago(119)).toBe("1m ago"); + expect(ago(59 * 60)).toBe("59m ago"); + }); + + test("steps up to hours and days", () => { + expect(ago(60 * 60)).toBe("1h ago"); + expect(ago(23 * 60 * 60)).toBe("23h ago"); + expect(ago(24 * 60 * 60)).toBe("1d ago"); + expect(ago(72 * 60 * 60)).toBe("3d ago"); + }); + + test("treats a future timestamp as 'just now' rather than going negative", () => { + expect(formatTimeAgo(now + 60_000, now)).toBe("just now"); + }); +}); diff --git a/bun.lock b/bun.lock index 85be5df..ed06df4 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,7 @@ "@stylistic/eslint-plugin-jsx": "^3.0.1", "@stylistic/eslint-plugin-ts": "^3.0.1", "@tailwindcss/postcss": "^4.3.0", + "@types/bun": "^1.3.14", "@types/node": "^25.9.1", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", @@ -77,6 +78,7 @@ "@typescript-eslint/parser": "^8.23.0", "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.5.0", + "bun-types": "^1.3.14", "chalk": "^5.6.2", "cross-env": "^10.1.0", "dotenv": "^17.4.2", @@ -1081,6 +1083,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -1429,6 +1433,8 @@ "builtin-status-codes": ["builtin-status-codes@3.0.0", "", {}, "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bunyan": ["bunyan@1.8.15", "", { "optionalDependencies": { "dtrace-provider": "0.8.8", "moment": "2.30.1", "mv": "2.1.1", "safe-json-stringify": "1.2.0" }, "bin": { "bunyan": "bin/bunyan" } }, "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig=="], diff --git a/package.json b/package.json index 27220ff..4a12112 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,12 @@ "lint:fix": "oxlint . --fix", "format": "oxfmt . --write", "format:check": "oxfmt . --check", + "test": "bun test", + "test:watch": "bun test --watch", "validate:i18n": "i18n-check -l apps/extension/src/localization/resources -s en -f i18next", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "check": "bun run typecheck && bun run lint && bun run format:check" + "check": "bun run typecheck && bun run lint && bun run format:check && bun run test" }, "dependencies": { "@base-ui/react": "^1.5.0", @@ -93,6 +95,7 @@ "@stylistic/eslint-plugin-jsx": "^3.0.1", "@stylistic/eslint-plugin-ts": "^3.0.1", "@tailwindcss/postcss": "^4.3.0", + "@types/bun": "^1.3.14", "@types/node": "^25.9.1", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", @@ -101,6 +104,7 @@ "@typescript-eslint/parser": "^8.23.0", "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.5.0", + "bun-types": "^1.3.14", "chalk": "^5.6.2", "cross-env": "^10.1.0", "dotenv": "^17.4.2", From f651023d0c5a59612132c3ceed9a73faed682522 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 02:50:05 +0300 Subject: [PATCH 006/124] feat(liquid): load the smplx wasm module in the extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `loadSmplxWasm`, deliberately mirroring `loadLwkWasm`: the same streaming-with-fallback instantiation and the same wasm-bindgen start handshake, because both modules are produced the same way and a second shape here would be a difference nobody could explain later. Unlike lwk it needs no network, so it can initialize in any context the extension runs in rather than only where a `window` exists. The package is wired the way lwk already is — a `file:` dependency on the built package inside the submodule — plus a `build:wasm` script, which lwk does not have. Without it, producing that directory is undocumented knowledge that lives only in working copies. The ambient module declaration joins lwk's in `vite-env.d.ts`, since wasm-pack emits no types for the `_bg.js` entry point. Tests instantiate the same bindings the loader consumes, differing only in where the module bytes come from. They assert the module reports its SDK version, compiles the probe contract to 43041b02608dc3ba245a2e3dc7aa5bc991fcf6c097c6a165a18e97a486461729 — the CMR a native build produces for the same source — derives a covenant address, and refuses both an uncompilable source and an unknown network. That the optimised wasm build agrees with a native build is what makes recomputing a covenant address in the wallet meaningful. --- .../adapters/smplx/loadSmplxWasm.test.ts | 75 +++++++++++++++++++ .../liquid/adapters/smplx/loadSmplxWasm.ts | 66 ++++++++++++++++ apps/extension/src/vite-env.d.ts | 5 ++ bun.lock | 3 + package.json | 2 + smplx | 2 +- 6 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts new file mode 100644 index 0000000..57e6d0f --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -0,0 +1,75 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; + +import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js"; + +// Exercises the exact bindings `loadSmplxWasm` consumes. The only difference is where +// the module bytes come from: the extension fetches them through a Vite asset URL, this +// reads them off disk. Everything after instantiation — the `__wbg_set_wasm` handshake, +// the start call, and every exported binding — is the same code path. +// +// `loadSmplxWasm` itself cannot be imported here: it uses Vite's `?url` import, which +// only resolves under Vite. + +type SmplxBindings = typeof import("smplx-wasm") & { + __wbg_set_wasm: (exports: WebAssembly.Exports) => void; +}; + +const bindings = smplxWasmBindings as unknown as SmplxBindings; + +// The reference value: this source compiled natively against simplicityhl 0.6.0 with +// debug symbols off. Asserting the wasm build reproduces it is what makes recomputing a +// covenant address in the wallet meaningful — a browser that derived a different CMR +// would refuse every legitimately deployed protocol. +const PROBE_SOURCE = "fn main() { assert!(jet::eq_32(witness::A, witness::B)); }"; +const PROBE_CMR = "43041b02608dc3ba245a2e3dc7aa5bc991fcf6c097c6a165a18e97a486461729"; + +beforeAll(async () => { + const require = createRequire(import.meta.url); + const wasmPath = require.resolve("smplx-wasm/smplx_wasm_bg.wasm"); + const bytes = await readFile(wasmPath); + + const { instance } = await WebAssembly.instantiate(bytes, { + "./smplx_wasm_bg.js": bindings as unknown as WebAssembly.ModuleImports, + }); + + bindings.__wbg_set_wasm(instance.exports); + + const start = instance.exports.__wbindgen_start; + + if (typeof start === "function") { + start(); + } +}); + +describe("smplx wasm module", () => { + test("reports the SDK version compiled into it", () => { + expect(bindings.sdkVersion()).toBe("0.0.9"); + }); + + test("compiles a contract to the same CMR as a native build", () => { + const contract = new bindings.Contract(PROBE_SOURCE); + + expect(contract.commitmentMerkleRoot()).toBe(PROBE_CMR); + }); + + test("derives a covenant address", () => { + const contract = new bindings.Contract(PROBE_SOURCE); + const address = contract.covenantAddress("liquid-testnet"); + + expect(address.startsWith("tex1p")).toBe(true); + }); + + test("refuses a source that does not compile", () => { + const contract = new bindings.Contract("fn main() { this is not simplicityhl }"); + + expect(() => contract.commitmentMerkleRoot()).toThrow(); + }); + + test("rejects an unknown network by name", () => { + const contract = new bindings.Contract(PROBE_SOURCE); + + expect(() => contract.covenantAddress("not-a-network")).toThrow(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts new file mode 100644 index 0000000..72e302a --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts @@ -0,0 +1,66 @@ +/* eslint-disable no-underscore-dangle */ + +import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js"; +import smplxWasmUrl from "smplx-wasm/smplx_wasm_bg.wasm?url"; + +export type SmplxWasmModule = typeof import("smplx-wasm"); + +type SmplxWasmBindings = SmplxWasmModule & { + __wbg_set_wasm: (exports: WebAssembly.Exports) => void; +}; + +const bindings = smplxWasmBindings as unknown as SmplxWasmBindings; + +let smplxWasmInitializePromise: Promise<void> | null = null; + +/** + * Loads the Simplex SDK wasm module, initializing it once per execution context. + * + * Deliberately mirrors `loadLwkWasm`: same streaming-with-fallback instantiation and the + * same wasm-bindgen start handshake, because both modules are produced the same way and a + * second shape here would be a difference nobody could explain later. + * + * Unlike lwk, this module needs no network, so it can be initialized in any context the + * extension runs in rather than only where a `window` exists. + */ +export async function loadSmplxWasm(): Promise<SmplxWasmModule> { + smplxWasmInitializePromise ??= initializeSmplxWasm(); + + await smplxWasmInitializePromise; + + return bindings; +} + +async function initializeSmplxWasm(): Promise<void> { + const imports = { + "./smplx_wasm_bg.js": bindings as unknown as WebAssembly.ModuleImports, + }; + const instance = await instantiateSmplxWasm(imports); + + bindings.__wbg_set_wasm(instance.exports); + startSmplxWasm(instance.exports); +} + +async function instantiateSmplxWasm(imports: WebAssembly.Imports): Promise<WebAssembly.Instance> { + const response = await fetch(smplxWasmUrl); + + try { + const { instance } = await WebAssembly.instantiateStreaming(response, imports); + + return instance; + } catch { + const fallbackResponse = await fetch(smplxWasmUrl); + const bytes = await fallbackResponse.arrayBuffer(); + const { instance } = await WebAssembly.instantiate(bytes, imports); + + return instance; + } +} + +function startSmplxWasm(exports: WebAssembly.Exports): void { + const start = exports.__wbindgen_start; + + if (typeof start === "function") { + start(); + } +} diff --git a/apps/extension/src/vite-env.d.ts b/apps/extension/src/vite-env.d.ts index 1bacd5a..63ee7c0 100644 --- a/apps/extension/src/vite-env.d.ts +++ b/apps/extension/src/vite-env.d.ts @@ -11,3 +11,8 @@ declare module "lwk_wasm/lwk_wasm_bg.js" { export * from "lwk_wasm"; export function __wbg_set_wasm(exports: WebAssembly.Exports): void; } + +declare module "smplx-wasm/smplx_wasm_bg.js" { + export * from "smplx-wasm"; + export function __wbg_set_wasm(exports: WebAssembly.Exports): void; +} diff --git a/bun.lock b/bun.lock index ed06df4..987767a 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "react-qr-code": "^2.2.0", "recharts": "^3.8.1", "shadcn": "^4.10.0", + "smplx-wasm": "file:smplx/crates/wasm/pkg", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2", @@ -2945,6 +2946,8 @@ "slow-redact": ["slow-redact@0.3.2", "", {}, "sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw=="], + "smplx-wasm": ["smplx-wasm@file:smplx/crates/wasm/pkg", {}], + "snapdragon": ["snapdragon@0.8.2", "", { "dependencies": { "base": "^0.11.1", "debug": "^2.2.0", "define-property": "^0.2.5", "extend-shallow": "^2.0.1", "map-cache": "^0.2.2", "source-map": "^0.5.6", "source-map-resolve": "^0.5.0", "use": "^3.1.0" } }, "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg=="], "snapdragon-node": ["snapdragon-node@2.1.1", "", { "dependencies": { "define-property": "^1.0.0", "isobject": "^3.0.0", "snapdragon-util": "^3.0.1" } }, "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw=="], diff --git a/package.json b/package.json index 4a12112..681ce4c 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "vite", "start": "vite", "build": "tsc && vite build", + "build:wasm": "smplx/crates/wasm/build.sh", "build:watch": "vite build --watch --mode development", "analyze": "vite build --mode analyze", "preview": "vite preview", @@ -77,6 +78,7 @@ "react-qr-code": "^2.2.0", "recharts": "^3.8.1", "shadcn": "^4.10.0", + "smplx-wasm": "file:smplx/crates/wasm/pkg", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2", diff --git a/smplx b/smplx index b7e9acc..d9b0562 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit b7e9acc0e2766e927920a223b929568feae503af +Subproject commit d9b0562d404e0e1a02f7b3f3ffa8d1d757aa2fbc From cbd7224c9c1e57a0ed2bf605100c67b063b41970 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 02:55:39 +0300 Subject: [PATCH 007/124] feat(liquid): accept a manifest action request and refuse an incomplete one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request contract has six parts: the manifest, the sources of the contracts it references, the chosen action and its filled parameters, and the two mutable protocol files the site holds — the instance file and the state file. The fee is deliberately absent; the wallet establishes it. Two separate questions, answered separately. `parseLiquidProcessCtParams` checks the request is well-formed. `resolveActionRequirements` reads the chosen action to work out what that action actually needs, and reports what of it is absent — so a refusal can name the missing part and, where the part is a map, the exact keys. Requiring all six parts of every request would refuse valid ones: a protocol with no covenant parameters has no instance file, and an action that creates rather than spends reads nothing from state. The published p2pk manifest shows both cases in one document — `Pay` needs neither file, `Receive` locates its covenant UTXO through the state file — and it is the fixture the tests run against, unmodified at the corpus commit they name. `resolveActionRequirements` is knowingly narrow: it looks for referenced contract sources, instance references, state lookups and declared parameters, and it is not a general construct registry. The registry is a later slice and should replace this rather than grow out of it. Fixtures are excluded from the formatter, since a fixture that is a copy of a published document stops being that document once it is reformatted. --- .oxfmtrc.json | 1 + .../manifest/__fixtures__/p2pk.manifest.json | 133 ++++++++++ .../domain/manifest/requirements.test.ts | 93 +++++++ .../liquid/domain/manifest/requirements.ts | 233 ++++++++++++++++++ .../chains/liquid/domain/manifest/types.ts | 58 +++++ .../liquid/domain/manifest/validation.ts | 38 +++ apps/extension/src/core/wallet-rpc/errors.ts | 1 + 7 files changed, 557 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.manifest.json create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/types.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/validation.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b6f7581..0a5cb7f 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,6 +11,7 @@ "ignorePatterns": [ "lwk/**", "smplx/**", + "**/__fixtures__/**", "AGENTS.md", "CLAUDE.md", "PROJECT_WORKFLOW.md", diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.manifest.json b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.manifest.json new file mode 100644 index 0000000..85bca28 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.manifest.json @@ -0,0 +1,133 @@ +{ + "manifest_version": "0.1.0", + "attestation_version": "1", + "protocol": "p2pk-simplicity", + "description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.", + "chain": "liquid", + "utxo_types": { + "p2pk_output": { + "description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.", + "script": { + "type": "simplicity", + "source": "./p2pk.simf" + }, + "asset": "lbtc", + "confidential": false + } + }, + "actions": { + "Pay": { + "description": "Lock funds into a p2pk output that only PUBKEY's owner can spend.", + "params": { + "pubkey": { + "type": "pubkey", + "description": "The x-only public key that will be able to spend this output (the recipient)." + }, + "amount_sat": { + "type": "u64", + "description": "Amount in satoshis to lock in the output." + } + }, + "inputs": [ + { + "id": "funding_input", + "description": "Wallet UTXO providing the funds.", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { + "min_amount": "params.amount_sat" + } + } + ], + "outputs": [ + { + "id": "p2pk_out", + "description": "The funded p2pk output, locked to PUBKEY.", + "destination": { + "utxo_type": "p2pk_output", + "compile_params": { + "PUB_KEY": "params.pubkey" + } + }, + "amount_sat": "params.amount_sat", + "asset": "lbtc" + }, + { + "id": "change_out", + "description": "Change returned to the funding wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "amount_nonzero", + "rule": { + "type": "arithmetic", + "expr": "params.amount_sat > 0" + }, + "error": { + "code": "INVALID_AMOUNT", + "message": "Amount must be greater than zero" + } + } + ] + }, + "Receive": { + "description": "Spend a p2pk output back into your wallet. Requires a BIP340 signature from the pubkey the output was locked to.", + "params": { + "pubkey": { + "type": "pubkey", + "description": "The x-only public key the output was locked to in Pay. Must be one of your own wallet's keys so the wallet can sign the spend." + } + }, + "inputs": [ + { + "id": "p2pk_in", + "description": "The p2pk covenant UTXO to spend, located via the state file by its utxo_type.", + "utxo_source": { + "utxo_type": "p2pk_output", + "compile_params": { + "PUB_KEY": "params.pubkey" + } + }, + "witnesses": { + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "params.pubkey" + }, + "description": "BIP340 Schnorr signature over the whole transaction, from the recipient key." + } + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO to pay the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true + } + ], + "outputs": [ + { + "id": "received_out", + "description": "The reclaimed funds, sent to your wallet.", + "destination": "wallet", + "asset": "lbtc", + "amount_sat": "p2pk_in.amount_sat" + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + } + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts new file mode 100644 index 0000000..55af23c --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; + +import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import { resolveActionRequirements } from "./requirements"; +import type { ParsedLiquidProcessCtParams } from "./types"; + +// The fixture is the published p2pk manifest at txmanifest-wallet +// 7d56516a1a1e44a586f25d45a34619c3953758dd, unmodified. Expectations below come from +// what that document says an action needs, not from what the resolver happens to return. + +const SOURCE_PATH = "./p2pk.simf"; +const PUBKEY = "0".repeat(64); + +function request( + overrides: Partial<ParsedLiquidProcessCtParams> = {}, +): ParsedLiquidProcessCtParams { + return { + action: "Pay", + broadcast: false, + contractSources: { [SOURCE_PATH]: "fn main() {}" }, + manifest: p2pkManifest as unknown as Record<string, unknown>, + params: { amount_sat: 1000, pubkey: PUBKEY }, + ...overrides, + }; +} + +describe("resolveActionRequirements", () => { + test("refuses an action the manifest does not declare, naming it", () => { + const { missing } = resolveActionRequirements(request({ action: "Withdraw" })); + + expect(missing).toHaveLength(1); + expect(missing[0]?.reason).toContain("Withdraw"); + }); + + // Pay locks funds into a new p2pk output: one wallet input, a covenant destination. + // It reads no deployment state, so a request without instance or state is complete. + describe("Pay", () => { + test("needs the contract source and the declared parameters", () => { + const { required } = resolveActionRequirements(request()); + + expect(required).toContain("contractSources"); + expect(required).toContain("params"); + }); + + test("does not need the instance or state files", () => { + const { required } = resolveActionRequirements(request()); + + expect(required).not.toContain("instance"); + expect(required).not.toContain("state"); + }); + + test("is complete when the source and parameters are supplied", () => { + expect(resolveActionRequirements(request()).missing).toEqual([]); + }); + + test("names the contract source that was not supplied", () => { + const { missing } = resolveActionRequirements(request({ contractSources: {} })); + const entry = missing.find((item) => item.part === "contractSources"); + + expect(entry?.keys).toEqual([SOURCE_PATH]); + }); + + test("names each parameter the request did not fill", () => { + const { missing } = resolveActionRequirements(request({ params: { pubkey: PUBKEY } })); + const entry = missing.find((item) => item.part === "params"); + + expect(entry?.keys).toEqual(["amount_sat"]); + }); + }); + + // Receive spends the covenant UTXO, which the manifest locates by utxo_type — a lookup + // into the state file rather than into the chain. + describe("Receive", () => { + const receive = (overrides: Partial<ParsedLiquidProcessCtParams> = {}) => + resolveActionRequirements( + request({ action: "Receive", params: { pubkey: PUBKEY }, ...overrides }), + ); + + test("needs the state file", () => { + expect(receive().required).toContain("state"); + }); + + test("refuses without it, saying why", () => { + const entry = receive().missing.find((item) => item.part === "state"); + + expect(entry?.reason).toContain("state file"); + }); + + test("is complete once the state file is supplied", () => { + expect(receive({ state: { utxos: [] } }).missing).toEqual([]); + }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts b/apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts new file mode 100644 index 0000000..0310d1e --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts @@ -0,0 +1,233 @@ +import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } from "./types"; + +/** + * Works out what the chosen action actually needs from the request, and what of that is + * absent — so a request can be refused before anything is built, naming what was missing. + * + * It answers the question by reading the action, not by checking the request's shape: a + * manifest with no covenant parameters needs no instance file, and an action that creates + * rather than spends reads nothing from state. Requiring all six parts of every request + * would refuse valid ones; requiring none would fail later and less legibly. + * + * Scope note: this walks the action looking for four things — referenced contract sources, + * `instance.` references, state-file lookups, and declared parameters. It is deliberately + * not a general construct registry; that is a later slice's job, and this should be + * replaced by it rather than grown. + */ +export function resolveActionRequirements( + request: ParsedLiquidProcessCtParams, +): ActionRequirements { + const action = findAction(request.manifest, request.action); + + if (!action) { + return { + missing: [ + { + part: "params", + reason: `The manifest declares no action named "${request.action}".`, + }, + ], + required: [], + }; + } + + const required: ActionRequirements["required"] = []; + const missing: MissingPart[] = []; + + const sources = referencedContractSources(request.manifest, action); + + if (sources.length > 0) { + required.push("contractSources"); + + const absent = sources.filter((path) => !(path in request.contractSources)); + + if (absent.length > 0) { + missing.push({ + keys: absent, + part: "contractSources", + reason: "The action builds contracts whose source was not supplied.", + }); + } + } + + const params = declaredParams(action); + const unfilled = params.filter((name) => !(name in request.params)); + + if (params.length > 0) { + required.push("params"); + } + + if (unfilled.length > 0) { + missing.push({ + keys: unfilled, + part: "params", + reason: "The action declares parameters the request did not fill.", + }); + } + + if (referencesInstance(action)) { + required.push("instance"); + + if (!request.instance) { + missing.push({ + part: "instance", + reason: "The action reads this deployment's field values.", + }); + } + } + + if (readsState(action)) { + required.push("state"); + + if (!request.state) { + missing.push({ + part: "state", + reason: "The action spends a covenant UTXO, which is located through the state file.", + }); + } + } + + return { missing, required }; +} + +/** + * Finds an action by name. Manifests declare them either flat under `actions` or grouped + * as `methods` inside a class, and a file may carry both; the two are structurally + * identical, so either spelling resolves here. + */ +function findAction( + manifest: Record<string, unknown>, + name: string, +): Record<string, unknown> | undefined { + const flat = asRecord(manifest.actions)?.[name]; + + if (isRecord(flat)) { + return flat; + } + + const classes = asRecord(manifest.classes); + + for (const declared of Object.values(classes ?? {})) { + const method = asRecord(asRecord(declared)?.methods)?.[name]; + + if (isRecord(method)) { + return method; + } + } + + return undefined; +} + +/** Contract source paths the action reaches, through the utxo types it names. */ +function referencedContractSources( + manifest: Record<string, unknown>, + action: Record<string, unknown>, +): string[] { + const utxoTypes = asRecord(manifest.utxo_types) ?? {}; + const named = new Set(collectStrings(action, "utxo_type")); + const paths = new Set<string>(); + + for (const [name, declared] of Object.entries(utxoTypes)) { + if (!named.has(name)) { + continue; + } + + const source = asRecord(asRecord(declared)?.script)?.source; + + if (typeof source === "string") { + paths.add(source); + } + } + + return [...paths]; +} + +/** Parameter names the action declares and therefore expects the request to fill. */ +function declaredParams(action: Record<string, unknown>): string[] { + const params = asRecord(action.params) ?? {}; + + // A param carrying a `source` or a `formula` is derived rather than prompted for. + return Object.entries(params) + .filter(([, declared]) => { + const record = asRecord(declared); + + return !record || (!("source" in record) && !("formula" in record)); + }) + .map(([name]) => name); +} + +/** Whether the action reads this deployment's field values under either spelling. */ +function referencesInstance(action: Record<string, unknown>): boolean { + return collectStringValues(action).some( + (value) => /(^|\$)instance\./.test(value) || /(^|\$)compile_params\./.test(value), + ); +} + +/** Whether the action spends a covenant UTXO, which is a lookup into the state file. */ +function readsState(action: Record<string, unknown>): boolean { + const inputs = Array.isArray(action.inputs) ? action.inputs : []; + + return inputs.some((input) => { + const source = asRecord(input)?.utxo_source; + + return isRecord(source) && "utxo_type" in source; + }); +} + +/** Every string value under `key`, at any depth. */ +function collectStrings(value: unknown, key: string): string[] { + const found: string[] = []; + + walk(value, (node) => { + const candidate = node[key]; + + if (typeof candidate === "string") { + found.push(candidate); + } + }); + + return found; +} + +/** Every string value at any depth, used to spot reference-shaped text. */ +function collectStringValues(value: unknown): string[] { + const found: string[] = []; + + walk(value, (node) => { + for (const entry of Object.values(node)) { + if (typeof entry === "string") { + found.push(entry); + } + } + }); + + return found; +} + +function walk(value: unknown, visit: (node: Record<string, unknown>) => void): void { + if (Array.isArray(value)) { + for (const entry of value) { + walk(entry, visit); + } + + return; + } + + if (!isRecord(value)) { + return; + } + + visit(value); + + for (const entry of Object.values(value)) { + walk(entry, visit); + } +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asRecord(value: unknown): Record<string, unknown> | undefined { + return isRecord(value) ? value : undefined; +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/types.ts b/apps/extension/src/core/chains/liquid/domain/manifest/types.ts new file mode 100644 index 0000000..855a720 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/types.ts @@ -0,0 +1,58 @@ +/** + * The request a site sends to perform one action of a txManifest protocol. + * + * Six parts, per the accepted request contract: the manifest, the sources of the + * contracts it references, the chosen action and its filled parameters, and the two + * mutable protocol files the site holds — the instance file (this deployment's field + * values) and the state file (its live covenant UTXO set). + * + * The last two are optional at the wire level because not every manifest needs them — + * a protocol with no covenant parameters has no instance file, and an action that + * creates rather than spends has nothing to read from state. Whether a *specific* + * action can proceed without them is a different question, answered by inspecting what + * that action actually references rather than by the shape of the request. + * + * The fee is deliberately absent. The wallet establishes the fee and the fee rate; a + * fee-like value arriving here would be ignored rather than honoured. + */ +export type ParsedLiquidProcessCtParams = { + /** The chosen action's name, as it appears in the manifest. */ + action: string; + /** Whether to broadcast the finished transaction or return it unsent. */ + broadcast: boolean; + /** + * Source text of every contract the manifest references, keyed by the path the + * manifest uses. Sources are not published with a manifest; they arrive here. + */ + contractSources: Record<string, string>; + /** This deployment's field values, when the protocol has any. */ + instance?: Record<string, unknown>; + /** The txManifest document itself. */ + manifest: Record<string, unknown>; + /** The filled parameters of the chosen action. */ + params: Record<string, unknown>; + /** The deployment's live covenant UTXO set, when the action reads one. */ + state?: Record<string, unknown>; +}; + +/** One request part, named the way a refusal message names it. */ +export type RequestPart = "contractSources" | "instance" | "params" | "state"; + +/** + * What a specific action needs from the request, and what of that is absent. + * + * `missing` is what makes a refusal answerable: it names the part and, where the part + * is a map, the exact keys that were referenced and not supplied. + */ +export type ActionRequirements = { + missing: MissingPart[]; + required: RequestPart[]; +}; + +export type MissingPart = { + /** Which keys were referenced and not supplied, when the part is a map. */ + keys?: string[]; + part: RequestPart; + /** Why the action needs it, in the manifest's own terms. */ + reason: string; +}; diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/validation.ts b/apps/extension/src/core/chains/liquid/domain/manifest/validation.ts new file mode 100644 index 0000000..9e378fd --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/validation.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError } from "@/core/wallet-rpc/errors"; + +import type { ParsedLiquidProcessCtParams } from "./types"; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); + +const processCtParamsSchema = z + .object({ + action: z.string().min(1).max(256), + broadcast: z.boolean().optional().default(false), + contractSources: z.record(z.string().min(1), z.string().min(1).max(1_000_000)), + instance: jsonObjectSchema.optional(), + manifest: jsonObjectSchema, + params: jsonObjectSchema.optional().default({}), + state: jsonObjectSchema.optional(), + }) + .strict(); + +/** + * Checks the request is well-formed. Whether the chosen action can actually be built + * from it is a separate question — see `resolveActionRequirements`, which reads the + * manifest rather than the request's shape. + */ +export function parseLiquidProcessCtParams(value: unknown): ParsedLiquidProcessCtParams { + const parsed = processCtParamsSchema.safeParse(value); + + if (!parsed.success) { + throw new WalletRpcInvalidParamsError( + "Invalid processConfidentialTransaction parameters.", + parsed.error.flatten(), + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return parsed.data; +} diff --git a/apps/extension/src/core/wallet-rpc/errors.ts b/apps/extension/src/core/wallet-rpc/errors.ts index 2e2a97e..408ae89 100644 --- a/apps/extension/src/core/wallet-rpc/errors.ts +++ b/apps/extension/src/core/wallet-rpc/errors.ts @@ -18,6 +18,7 @@ export const WALLET_RPC_ERROR_REASONS = { INVALID_IDENTITY_PUBLIC_KEY: "invalid_identity_public_key", INVALID_IDENTITY_REQUEST: "invalid_identity_request", INVALID_LOCAL_ROOT_MATERIAL: "invalid_local_root_material", + INVALID_MANIFEST_REQUEST: "invalid_manifest_request", INVALID_MESSAGE_SIGNING_REQUEST: "invalid_message_signing_request", INVALID_PARAMS: "invalid_params", INVALID_PSET_REQUEST: "invalid_pset_request", From 8cfeef5695ac4a63e51842741d1a500750235764 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 02:57:42 +0300 Subject: [PATCH 008/124] test(liquid): add the canonical p2pk contract as a fixture Taken from simplicityhl 0.6.0's own examples rather than written here, so the tracer's compile-and-derive path is checked against the compiler author's contract instead of against something we invented. Two identifiers renamed to match the published manifest's compile parameter and witness names; nothing else changed. --- .../liquid/domain/manifest/__fixtures__/p2pk.simf | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.simf diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.simf b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.simf new file mode 100644 index 0000000..1004d06 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.simf @@ -0,0 +1,10 @@ +/* + * PAY TO PUBLIC KEY + * + * The coins move if the person with the given public key signs the transaction. + * + * https://docs.ivylang.org/bitcoin/language/ExampleContracts.html#lockwithpublickey + */ +fn main() { + jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) +} From 765b99c4ad398925dc557dacef6ed550e9fa0b24 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:00:37 +0300 Subject: [PATCH 009/124] test(liquid): assert compile parameters change the covenant address The wallet proves a contract is genuine by rebuilding it and checking the address matches where the funds sit. That check means nothing unless different compile parameters genuinely produce different addresses, and the same ones reproduce the same address, so both are asserted rather than assumed. Also asserted: a parameterised contract given no parameters is refused rather than compiled against defaults, and malformed argument JSON is refused when the contract is constructed. --- .../adapters/smplx/loadSmplxWasm.test.ts | 45 +++++++++++++++++++ smplx | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 57e6d0f..d7dd9df 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -73,3 +73,48 @@ describe("smplx wasm module", () => { expect(() => contract.covenantAddress("not-a-network")).toThrow(); }); }); + +// Compile-time parameters are what make one contract source into many covenant +// addresses. The address check the wallet performs is only meaningful if different +// parameters genuinely produce different addresses, so that is asserted rather than +// assumed. +describe("contract parameters", () => { + const P2PK_SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + + const args = (pubkey: string) => JSON.stringify({ PUB_KEY: { type: "Pubkey", value: pubkey } }); + + // Generator points for 1*G and 2*G, from simplicityhl's own example fixtures. + const ALICE = "0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const BOB = "0xc6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + + test("compiles a parameterised contract", () => { + const contract = new bindings.Contract(P2PK_SOURCE, args(ALICE)); + + expect(contract.commitmentMerkleRoot()).toMatch(/^[0-9a-f]{64}$/); + }); + + test("different parameters produce different covenant addresses", () => { + const alice = new bindings.Contract(P2PK_SOURCE, args(ALICE)); + const bob = new bindings.Contract(P2PK_SOURCE, args(BOB)); + + expect(alice.covenantAddress("liquid-testnet")).not.toBe(bob.covenantAddress("liquid-testnet")); + }); + + test("the same parameters produce the same covenant address", () => { + const first = new bindings.Contract(P2PK_SOURCE, args(ALICE)); + const second = new bindings.Contract(P2PK_SOURCE, args(ALICE)); + + expect(first.covenantAddress("liquid-testnet")).toBe(second.covenantAddress("liquid-testnet")); + }); + + test("refuses malformed argument JSON when the contract is constructed", () => { + expect(() => new bindings.Contract(P2PK_SOURCE, "{ not json")).toThrow(); + }); + + test("refuses a parameterised contract given no parameters", () => { + const contract = new bindings.Contract(P2PK_SOURCE); + + expect(() => contract.commitmentMerkleRoot()).toThrow(); + }); +}); diff --git a/smplx b/smplx index d9b0562..0f7557e 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit d9b0562d404e0e1a02f7b3f3ffa8d1d757aa2fbc +Subproject commit 0f7557e03c02c5b791cbd65420b35f9b1e4c81b6 From 310a74ad561f9590f59ae94b6c0f2ec9388e1cd0 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:00:56 +0300 Subject: [PATCH 010/124] build: advance the smplx submodule past the parameterised-contract bindings --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 0f7557e..785de71 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 0f7557e03c02c5b791cbd65420b35f9b1e4c81b6 +Subproject commit 785de7188a57fa183d8094129ef4bca52ad8b20b From 6c29e8009fc7becefd82e4098c2d35e26ca91d68 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:02:21 +0300 Subject: [PATCH 011/124] feat(liquid): resolve a contract's compile-time parameters from the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A covenant address is derived from a contract source and the parameters it was built with. The manifest wires those parameters to references — {"PUB_KEY": "params.pubkey"} — and the request fills them, so something has to join the two and hand the compiler its own argument shape. Everything it cannot resolve refuses rather than resolving to something plausible: a reference into the instance file, a declared type nobody has mapped, a parameter with no declared type, a parameter the request left out. That strictness is the point — these values participate in the address, so a wrong one produces a well-formed address for the wrong contract instead of an error. The type map is a closed list holding one entry. The corpus's other declared types arrive with the slices that need them. Both reference spellings the corpus carries are accepted: lending uses the $-prefixed form where lending_v2 uses the bare one. --- .../domain/manifest/compileParams.test.ts | 100 ++++++++++++++++++ .../liquid/domain/manifest/compileParams.ts | 99 +++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts new file mode 100644 index 0000000..888a3ee --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveCompileParams } from "./compileParams"; +import type { ParsedLiquidProcessCtParams } from "./types"; + +// The wiring and the declared types below are the published p2pk manifest's own: +// `Pay` writes a destination with compile_params {"PUB_KEY": "params.pubkey"}, and the +// action declares `pubkey` with type "pubkey". + +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +function request(params: Record<string, unknown>): ParsedLiquidProcessCtParams { + return { + action: "Pay", + broadcast: false, + contractSources: {}, + manifest: {}, + params, + }; +} + +describe("resolveCompileParams", () => { + test("resolves a params reference into the compiler's argument shape", () => { + const result = resolveCompileParams( + request({ pubkey: PUBKEY }), + { PUB_KEY: "params.pubkey" }, + { pubkey: "pubkey" }, + ); + + expect(result).toEqual({ + arguments: { PUB_KEY: { type: "Pubkey", value: `0x${PUBKEY}` } }, + ok: true, + }); + }); + + test("accepts the $-prefixed spelling the corpus also uses", () => { + const result = resolveCompileParams( + request({ pubkey: PUBKEY }), + { PUB_KEY: "$params.pubkey" }, + { pubkey: "pubkey" }, + ); + + expect(result.ok).toBe(true); + }); + + test("leaves an already-prefixed value alone", () => { + const result = resolveCompileParams( + request({ pubkey: `0x${PUBKEY}` }), + { PUB_KEY: "params.pubkey" }, + { pubkey: "pubkey" }, + ); + + expect(result).toMatchObject({ + arguments: { PUB_KEY: { value: `0x${PUBKEY}` } }, + }); + }); + + test("refuses when the referenced parameter was not supplied", () => { + const result = resolveCompileParams( + request({}), + { PUB_KEY: "params.pubkey" }, + { pubkey: "pubkey" }, + ); + + expect(result.ok).toBe(false); + }); + + // A reference this runtime cannot resolve must refuse rather than resolve to something + // plausible: the value participates in the covenant address, so a wrong one produces a + // well-formed address for the wrong contract. + test("refuses an instance reference rather than guessing", () => { + const result = resolveCompileParams( + request({ pubkey: PUBKEY }), + { PUB_KEY: "instance.OWNER" }, + { pubkey: "pubkey" }, + ); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses a declared type it does not encode", () => { + const result = resolveCompileParams( + request({ owner: "someone" }), + { PUB_KEY: "params.owner" }, + { owner: "address" }, + ); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses a parameter with no declared type", () => { + const result = resolveCompileParams( + request({ pubkey: PUBKEY }), + { PUB_KEY: "params.pubkey" }, + {}, + ); + + expect(result).toMatchObject({ ok: false }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts new file mode 100644 index 0000000..8cc92b8 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts @@ -0,0 +1,99 @@ +import type { ParsedLiquidProcessCtParams } from "./types"; + +/** + * A contract's compile-time parameters, in SimplicityHL's own argument JSON shape. + * + * Kept as the compiler's format rather than a shape of our own so the value that reaches + * compilation is the value the compiler documents, with nothing translating in between. + */ +export type ContractArguments = Record<string, { type: string; value: string }>; + +export type ResolveCompileParamsResult = + | { arguments: ContractArguments; ok: true } + | { ok: false; reason: string }; + +/** + * The manifest's declared parameter types, mapped to the compiler's. + * + * Deliberately a closed list: a type nobody has mapped is refused rather than passed + * through, because a wrong type here produces a valid-looking wrong address rather than + * an error. The corpus's remaining types — the integer widths, `bytes32`, + * `liquid.asset_id` and `address` — arrive with the slices that need them. + */ +const PARAM_TYPES: Record<string, string> = { + pubkey: "Pubkey", +}; + +/** + * Resolves the compile-time parameters a contract is built with, from the manifest's + * wiring and the parameters the request filled. + * + * The wiring lives in `compile_params`, a map of the contract's parameter name to a + * reference — `{"PUB_KEY": "params.pubkey"}`. Note the collision the format carries: + * `compile_params` is both this wiring map and a deprecated namespace prefix for + * references. This reads the wiring; the namespace is a later slice's problem. + * + * Scope: resolves `params.` references only. Instance references and formulas belong to + * the slices that own them, and are refused here rather than silently mishandled. + */ +export function resolveCompileParams( + request: ParsedLiquidProcessCtParams, + wiring: Record<string, unknown>, + declaredTypes: Record<string, string>, +): ResolveCompileParamsResult { + const resolved: ContractArguments = {}; + + for (const [name, reference] of Object.entries(wiring)) { + if (typeof reference !== "string") { + return { ok: false, reason: `Compile parameter ${name} is not a reference.` }; + } + + const paramName = referencedParam(reference); + + if (!paramName) { + return { + ok: false, + reason: `Compile parameter ${name} references ${reference}, which this runtime does not resolve yet.`, + }; + } + + const value = request.params[paramName]; + + if (typeof value !== "string") { + return { + ok: false, + reason: `Compile parameter ${name} needs parameter ${paramName}, which the request did not supply as a value.`, + }; + } + + const declaredType = declaredTypes[paramName]; + const compilerType = declaredType ? PARAM_TYPES[declaredType] : undefined; + + if (!compilerType) { + return { + ok: false, + reason: `Parameter ${paramName} is declared as ${declaredType ?? "an unstated type"}, which this runtime does not encode yet.`, + }; + } + + resolved[name] = { type: compilerType, value: withHexPrefix(value) }; + } + + return { arguments: resolved, ok: true }; +} + +/** + * The action parameter a reference points at, or undefined when it points elsewhere. + * + * Accepts the `$`-prefixed spelling alongside the bare one: the corpus carries both, and + * `lending` uses one where `lending_v2` uses the other. + */ +function referencedParam(reference: string): string | undefined { + const match = /^\$?params\.(?<name>[A-Za-z0-9_]+)$/.exec(reference); + + return match?.groups?.name; +} + +function withHexPrefix(value: string): string { + return value.startsWith("0x") ? value : `0x${value}`; +} From 665df246d88e95a8eece916745950951f0f4120a Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:04:26 +0300 Subject: [PATCH 012/124] feat(liquid): derive a covenant address and check it against the chain Two functions, deliberately separate. deriveCovenantAddress rebuilds the contract from the source the request supplied and the parameters the manifest wires into it. The site's contribution changes what the wallet computes; it never changes what that result is checked against. covenantMatchesChain does the checking, and takes the on-chain address as an explicit argument. The state file carries an outpoint and no scriptPubKey, which is the right shape rather than a gap: comparing two values the same site supplied would pass for any pair it chose to make consistent. What sits at an outpoint is read from the network, not told by the requester. A mismatch returns a refusal with both addresses named. There is no shape of this that returns a warning. Compilation is injected, so both are exercised without a wasm module and the module's lifecycle stays where it belongs. --- .../liquid/domain/manifest/covenant.test.ts | 119 +++++++++++++++ .../chains/liquid/domain/manifest/covenant.ts | 136 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts new file mode 100644 index 0000000..9c7509e --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; + +import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import { covenantMatchesChain, deriveCovenantAddress } from "./covenant"; +import type { ParsedLiquidProcessCtParams } from "./types"; + +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const SOURCE_PATH = "./p2pk.simf"; +const SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + +// The published manifest's own wiring for the p2pk output. +const WIRING = { PUB_KEY: "params.pubkey" }; +const DECLARED_TYPES = { pubkey: "pubkey" }; + +function request( + overrides: Partial<ParsedLiquidProcessCtParams> = {}, +): ParsedLiquidProcessCtParams { + return { + action: "Receive", + broadcast: false, + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: p2pkManifest as unknown as Record<string, unknown>, + params: { pubkey: PUBKEY }, + ...overrides, + }; +} + +/** Stands in for the wasm module, recording what it was asked to compile. */ +function compiler(address = "tex1p_derived") { + const calls: { argumentsJson: string; network: string; source: string }[] = []; + + return { + calls, + compile: (input: { argumentsJson: string; network: string; source: string }) => { + calls.push(input); + + return address; + }, + }; +} + +describe("deriveCovenantAddress", () => { + test("compiles the source the request supplied, with the parameters the manifest wires", async () => { + const { calls, compile } = compiler(); + + const result = await deriveCovenantAddress(request(), { + compile, + declaredTypes: DECLARED_TYPES, + network: "liquid", + utxoType: "p2pk_output", + wiring: WIRING, + }); + + expect(result).toMatchObject({ derivation: { utxoType: "p2pk_output" }, ok: true }); + expect(calls).toHaveLength(1); + expect(calls[0]?.source).toBe(SOURCE); + expect(JSON.parse(calls[0]?.argumentsJson ?? "{}")).toEqual({ + PUB_KEY: { type: "Pubkey", value: `0x${PUBKEY}` }, + }); + }); + + test("refuses a utxo type the manifest does not declare", async () => { + const result = await deriveCovenantAddress(request(), { + compile: compiler().compile, + declaredTypes: DECLARED_TYPES, + network: "liquid", + utxoType: "vault", + wiring: WIRING, + }); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses when the contract source was not supplied", async () => { + const result = await deriveCovenantAddress(request({ contractSources: {} }), { + compile: compiler().compile, + declaredTypes: DECLARED_TYPES, + network: "liquid", + utxoType: "p2pk_output", + wiring: WIRING, + }); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses when the source does not compile, rather than throwing", async () => { + const result = await deriveCovenantAddress(request(), { + compile: () => { + throw new Error("parse error"); + }, + declaredTypes: DECLARED_TYPES, + network: "liquid", + utxoType: "p2pk_output", + wiring: WIRING, + }); + + expect(result).toMatchObject({ ok: false }); + }); +}); + +describe("covenantMatchesChain", () => { + const derivation = { address: "tex1p_derived", utxoType: "p2pk_output" }; + + test("matches when the rebuilt contract lands where the funds are", () => { + expect(covenantMatchesChain(derivation, "tex1p_derived")).toEqual({ matched: true }); + }); + + test("refuses when it does not, naming both addresses", () => { + const result = covenantMatchesChain(derivation, "tex1p_somewhere_else"); + + expect(result.matched).toBe(false); + + if (!result.matched) { + expect(result.reason).toContain("tex1p_derived"); + expect(result.reason).toContain("tex1p_somewhere_else"); + } + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts new file mode 100644 index 0000000..ccf6a38 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts @@ -0,0 +1,136 @@ +import { resolveCompileParams } from "./compileParams"; +import type { ParsedLiquidProcessCtParams } from "./types"; + +/** + * Compiles a covenant and reports the address it derives. + * + * The caller supplies the compile step, so this can be exercised without a wasm module + * and so the module's lifecycle stays where it belongs. + */ +export type CompileCovenant = (input: { + argumentsJson: string; + network: string; + source: string; +}) => Promise<string> | string; + +export type CovenantDerivation = { + /** The address the wallet derived by rebuilding the contract itself. */ + address: string; + /** The manifest's name for the kind of UTXO this is. */ + utxoType: string; +}; + +export type DeriveCovenantResult = + | { derivation: CovenantDerivation; ok: true } + | { ok: false; reason: string }; + +/** + * Derives the address of one covenant UTXO type, from the contract source the request + * supplied and the parameters the manifest wires into it. + * + * This is the wallet establishing a fact for itself. Nothing the site says about where + * the funds are is consulted; the site's contribution is the source text and the + * parameter values, and both change what is derived rather than what it is checked + * against. + */ +export async function deriveCovenantAddress( + request: ParsedLiquidProcessCtParams, + input: { + compile: CompileCovenant; + declaredTypes: Record<string, string>; + network: string; + utxoType: string; + wiring: Record<string, unknown>; + }, +): Promise<DeriveCovenantResult> { + const declared = utxoTypeDeclaration(request.manifest, input.utxoType); + + if (!declared) { + return { ok: false, reason: `The manifest declares no utxo type named "${input.utxoType}".` }; + } + + const sourcePath = declared.sourcePath; + + if (!sourcePath) { + return { ok: false, reason: `Utxo type "${input.utxoType}" names no contract source.` }; + } + + const source = request.contractSources[sourcePath]; + + if (source === undefined) { + return { ok: false, reason: `The source of ${sourcePath} was not supplied.` }; + } + + const params = resolveCompileParams(request, input.wiring, input.declaredTypes); + + if (!params.ok) { + return params; + } + + try { + const address = await input.compile({ + argumentsJson: JSON.stringify(params.arguments), + network: input.network, + source, + }); + + return { derivation: { address, utxoType: input.utxoType }, ok: true }; + } catch (error) { + return { + ok: false, + reason: `The contract at ${sourcePath} did not compile: ${String(error)}`, + }; + } +} + +/** + * Whether a covenant UTXO is what the manifest claims: does the address the wallet + * derived match the one the funds are actually sitting at? + * + * `onChainAddress` must come from the chain, never from the request. Comparing two + * values the same site supplied would pass for any pair it chose to make consistent. + * The state file carries an outpoint and no address precisely because the address has + * to be read rather than told. + * + * A mismatch is a refusal. There is no shape of this function that returns a warning. + */ +export function covenantMatchesChain( + derivation: CovenantDerivation, + onChainAddress: string, +): { matched: true } | { matched: false; reason: string } { + if (derivation.address === onChainAddress) { + return { matched: true }; + } + + return { + matched: false, + reason: + `The ${derivation.utxoType} contract rebuilds to ${derivation.address}, ` + + `but the funds are at ${onChainAddress}. This is not the contract the site described.`, + }; +} + +function utxoTypeDeclaration( + manifest: Record<string, unknown>, + name: string, +): { sourcePath: string | undefined } | undefined { + const utxoTypes = manifest.utxo_types; + + if (typeof utxoTypes !== "object" || utxoTypes === null) { + return undefined; + } + + const declared = (utxoTypes as Record<string, unknown>)[name]; + + if (typeof declared !== "object" || declared === null) { + return undefined; + } + + const script = (declared as Record<string, unknown>).script; + const source = + typeof script === "object" && script !== null + ? (script as Record<string, unknown>).source + : undefined; + + return { sourcePath: typeof source === "string" ? source : undefined }; +} From 7cd5150b2d4db0c9482b74be6035bdc05c129821 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:09:06 +0300 Subject: [PATCH 013/124] feat(liquid): read what the chain says is at an outpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The covenant address check needs a second value that the requester did not supply, and nothing the wallet ships can produce one. The wallet's UTXO snapshot only ever holds outputs it owns, and lwk's Esplora client scans a descriptor and broadcasts — it cannot fetch a transaction. So this reads the output directly from the endpoint the chain record already configures for lwk, headers included, so an authenticated backend does not have to be configured twice. What is read is public chain data: no key, no descriptor, no wallet state. That is what makes a direct read acceptable where a direct write would not be — broadcast stays with lwk. Every way the answer could be unusable fails loudly rather than returning something a comparison would silently match against: a malformed txid or index is refused before the request is made, and an output that comes back without a scriptPubKey is an error rather than an empty string. --- .../liquid/domain/manifest/chainRead.test.ts | 111 ++++++++++++++++++ .../liquid/domain/manifest/chainRead.ts | 93 +++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts new file mode 100644 index 0000000..9ada970 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; + +import { createEsploraTxOutReader } from "./chainRead"; + +// Response shapes are Esplora's documented ones: /tx/:txid returns a transaction whose +// vout entries carry scriptpubkey and scriptpubkey_address. +const TXID = "a".repeat(64); + +function respondWith(body: unknown, ok = true, status = 200) { + const calls: { init?: RequestInit; url: string }[] = []; + + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ init, url: String(url) }); + + return { + json: async () => body, + ok, + status, + } as Response; + }) as unknown as typeof fetch; + + return { calls, fetchImpl }; +} + +const OUTPUT = { + asset: "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d", + scriptpubkey: "5120aabb", + scriptpubkey_address: "tex1p_covenant", + value: 5000, +}; + +describe("createEsploraTxOutReader", () => { + test("reads the requested output", async () => { + const { fetchImpl } = respondWith({ + vout: [{ scriptpubkey: "00", scriptpubkey_address: "other" }, OUTPUT], + }); + const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read({ txid: TXID, vout: 1 })).resolves.toMatchObject({ + amountSats: "5000", + scriptPubKeyAddress: "tex1p_covenant", + scriptPubKeyHex: "5120aabb", + }); + }); + + test("asks the configured endpoint, trailing slash or not", async () => { + const { calls, fetchImpl } = respondWith({ vout: [OUTPUT] }); + const read = createEsploraTxOutReader({ url: "https://esplora.example/" }, fetchImpl); + + await read({ txid: TXID, vout: 0 }); + + expect(calls[0]?.url).toBe(`https://esplora.example/tx/${TXID}`); + }); + + // A private or authenticated backend is configured once, for lwk; this read must not + // need it configured a second time. + test("sends the endpoint's configured headers", async () => { + const { calls, fetchImpl } = respondWith({ vout: [OUTPUT] }); + const read = createEsploraTxOutReader( + { + headers: [{ name: "authorization", value: "Bearer token" }], + url: "https://esplora.example", + }, + fetchImpl, + ); + + await read({ txid: TXID, vout: 0 }); + + expect(calls[0]?.init?.headers).toMatchObject({ authorization: "Bearer token" }); + }); + + test("rejects something that is not a transaction id before asking anyone", async () => { + const { calls, fetchImpl } = respondWith({ vout: [OUTPUT] }); + const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read({ txid: "nope", vout: 0 })).rejects.toThrow(); + expect(calls).toHaveLength(0); + }); + + test("rejects a negative output index before asking anyone", async () => { + const { calls, fetchImpl } = respondWith({ vout: [OUTPUT] }); + const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read({ txid: TXID, vout: -1 })).rejects.toThrow(); + expect(calls).toHaveLength(0); + }); + + test("fails when the endpoint does not answer successfully", async () => { + const { fetchImpl } = respondWith({}, false, 404); + const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read({ txid: TXID, vout: 0 })).rejects.toThrow(); + }); + + test("fails when the transaction has no output at that index", async () => { + const { fetchImpl } = respondWith({ vout: [OUTPUT] }); + const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read({ txid: TXID, vout: 7 })).rejects.toThrow(); + }); + + // A confidential output has no scriptpubkey_address in some Esplora deployments; + // failing loudly beats returning an object with an empty address that a comparison + // would then match against nothing. + test("fails when the output came back without a scriptPubKey", async () => { + const { fetchImpl } = respondWith({ vout: [{ value: 1 }] }); + const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read({ txid: TXID, vout: 0 })).rejects.toThrow(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts new file mode 100644 index 0000000..18184c1 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts @@ -0,0 +1,93 @@ +/** + * Reading what the chain says sits at an outpoint. + * + * This exists because no component the wallet ships can answer that question: lwk's + * Esplora client scans a descriptor and broadcasts, and the wallet's own UTXO snapshot + * only ever contains outputs the wallet owns. A covenant output belongs to a contract. + * + * It is a read of public chain data — no key, no descriptor, no wallet state — which is + * what makes reaching the endpoint directly acceptable here where writing to it would + * not be. Broadcast stays with lwk. + */ + +export type OutPoint = { txid: string; vout: number }; + +export type TxOutAtOutPoint = { + /** Base-unit amount, when the output is explicit rather than confidential. */ + amountSats?: string; + /** Raw asset id, when the output is explicit rather than confidential. */ + rawAssetId?: string; + /** The address the output pays to, as the endpoint reports it. */ + scriptPubKeyAddress: string; + /** The output's scriptPubKey in hex. */ + scriptPubKeyHex: string; +}; + +export type ReadTxOut = (outpoint: OutPoint) => Promise<TxOutAtOutPoint>; + +export type EsploraEndpoint = { + headers?: { name: string; value: string }[]; + url: string; +}; + +/** + * Reads one output of one transaction from an Esplora endpoint. + * + * The endpoint is the one the chain record already configures for lwk, headers included, + * so a private or authenticated backend keeps working without being configured twice. + */ +export function createEsploraTxOutReader( + endpoint: EsploraEndpoint, + fetchImpl: typeof fetch = fetch, +): ReadTxOut { + const base = endpoint.url.replace(/\/+$/, ""); + + return async ({ txid, vout }) => { + if (!/^[0-9a-fA-F]{64}$/.test(txid)) { + throw new Error(`Not a transaction id: ${txid}`); + } + + if (!Number.isInteger(vout) || vout < 0) { + throw new Error(`Not an output index: ${vout}`); + } + + const response = await fetchImpl(`${base}/tx/${txid}`, { + headers: Object.fromEntries((endpoint.headers ?? []).map(({ name, value }) => [name, value])), + }); + + if (!response.ok) { + throw new Error(`Could not read transaction ${txid}: ${response.status}`); + } + + const body: unknown = await response.json(); + const outputs = isRecord(body) && Array.isArray(body.vout) ? body.vout : undefined; + + if (!outputs) { + throw new Error(`Transaction ${txid} came back without outputs.`); + } + + const output = outputs[vout]; + + if (!isRecord(output)) { + throw new Error(`Transaction ${txid} has no output at index ${vout}.`); + } + + const scriptPubKeyHex = output.scriptpubkey; + const scriptPubKeyAddress = output.scriptpubkey_address; + + if (typeof scriptPubKeyHex !== "string" || typeof scriptPubKeyAddress !== "string") { + throw new Error(`Output ${txid}:${vout} came back without a scriptPubKey.`); + } + + return { + ...(typeof output.value === "number" ? { amountSats: String(output.value) } : {}), + ...(typeof output.asset === "string" ? { rawAssetId: output.asset } : {}), + scriptPubKeyAddress, + scriptPubKeyHex, + }; + }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} From 1909e202bb21981cec1e5c05835a5b70b41dc90e Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:10:25 +0300 Subject: [PATCH 014/124] feat(liquid): establish what the wallet knows about an action before anyone approves it Ties the pieces together. For every covenant the action touches, the contract is rebuilt from the source the request supplied; a covenant being spent is then compared against what the chain says is at its outpoint, and a covenant being created is reported as derived-but-not-yet-on-chain rather than as verified. That distinction is the point. An action that creates a covenant has nothing to compare against, and saying so is more honest than reporting a check that did not happen. Its protection is different in kind: the destination is derived by the wallet rather than supplied by the site. Everything it cannot establish refuses, and the refusal says which thing: a missing request part named by key, a contract that will not compile, a state file listing no such covenant, a chain that cannot be read, an address that does not match. There is no return value meaning 'probably fine'. This runs before the permission gate, where a standing permission cannot skip it, which is why it refuses rather than warns. --- .../liquid/domain/manifest/review.test.ts | 173 +++++++++++++ .../chains/liquid/domain/manifest/review.ts | 239 ++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/review.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts new file mode 100644 index 0000000..c69da3d --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test"; + +import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import { isRefusal, reviewManifestAction } from "./review"; +import type { ParsedLiquidProcessCtParams } from "./types"; + +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const SOURCE_PATH = "./p2pk.simf"; +const SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; +const TXID = "b".repeat(64); +const DERIVED = "tex1p_derived"; + +function request( + overrides: Partial<ParsedLiquidProcessCtParams> = {}, +): ParsedLiquidProcessCtParams { + return { + action: "Pay", + broadcast: false, + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: p2pkManifest as unknown as Record<string, unknown>, + params: { amount_sat: 1000, pubkey: PUBKEY }, + ...overrides, + }; +} + +const compile = () => DERIVED; +const readTxOut = (address: string) => async () => ({ + scriptPubKeyAddress: address, + scriptPubKeyHex: "5120aabb", +}); + +const spendRequest = (state: unknown) => + request({ + action: "Receive", + params: { pubkey: PUBKEY }, + state: state as Record<string, unknown>, + }); + +const oneCovenantUtxo = { + utxos: [{ txid: TXID, utxo_type: "p2pk_output", vout: 0 }], +}; + +describe("reviewManifestAction", () => { + // Pay creates a covenant output. There is nothing on chain yet, so the wallet reports + // what it derived and says plainly that it has not compared it against anything. + describe("creating a covenant", () => { + test("reports the derived address as not yet on chain", async () => { + const result = await reviewManifestAction(request(), { + compile, + network: "liquid", + readTxOut: readTxOut("unused"), + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.covenants).toEqual([ + { + address: DERIVED, + role: "created", + utxoType: "p2pk_output", + verified: "not-yet-on-chain", + }, + ]); + } + }); + + test("never consults the chain for something that does not exist yet", async () => { + let asked = 0; + + await reviewManifestAction(request(), { + compile, + network: "liquid", + readTxOut: async () => { + asked += 1; + + return { scriptPubKeyAddress: "x", scriptPubKeyHex: "00" }; + }, + }); + + expect(asked).toBe(0); + }); + }); + + // Receive spends the covenant. This is where the wallet's derivation is checked against + // something it did not get from the requester. + describe("spending a covenant", () => { + test("passes when the rebuilt contract lands where the funds are", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + compile, + network: "liquid", + readTxOut: readTxOut(DERIVED), + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.covenants[0]).toMatchObject({ + role: "spent", + verified: "matches-chain", + }); + } + }); + + test("refuses when the funds are somewhere else", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + compile, + network: "liquid", + readTxOut: readTxOut("tex1p_somewhere_else"), + }); + + expect(isRefusal(result)).toBe(true); + }); + + test("refuses when the state file lists no such covenant", async () => { + const result = await reviewManifestAction(spendRequest({ utxos: [] }), { + compile, + network: "liquid", + readTxOut: readTxOut(DERIVED), + }); + + expect(isRefusal(result)).toBe(true); + }); + + test("refuses when the chain cannot be read, rather than proceeding unchecked", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + compile, + network: "liquid", + readTxOut: async () => { + throw new Error("offline"); + }, + }); + + expect(isRefusal(result)).toBe(true); + }); + + test("refuses before reading anything when the state file is absent", async () => { + const result = await reviewManifestAction( + request({ action: "Receive", params: { pubkey: PUBKEY } }), + { compile, network: "liquid", readTxOut: readTxOut(DERIVED) }, + ); + + expect(isRefusal(result)).toBe(true); + }); + }); + + test("refuses a request missing a part the action needs, naming it", async () => { + const result = await reviewManifestAction(request({ contractSources: {} }), { + compile, + network: "liquid", + readTxOut: readTxOut(DERIVED), + }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain(SOURCE_PATH); + } + }); + + test("refuses when the contract does not compile", async () => { + const result = await reviewManifestAction(request(), { + compile: () => { + throw new Error("parse error"); + }, + network: "liquid", + readTxOut: readTxOut(DERIVED), + }); + + expect(isRefusal(result)).toBe(true); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts new file mode 100644 index 0000000..dcaded2 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -0,0 +1,239 @@ +import type { ReadTxOut } from "./chainRead"; +import { type CompileCovenant, covenantMatchesChain, deriveCovenantAddress } from "./covenant"; +import { resolveActionRequirements } from "./requirements"; +import type { ParsedLiquidProcessCtParams } from "./types"; + +/** + * What the wallet established for itself about one covenant this action touches. + * + * `verified` is the wallet's own finding, never the site's claim. A covenant the action + * creates has nothing to compare against yet — its protection is that the destination is + * derived rather than supplied — and says so rather than reporting a check it did not do. + */ +export type CovenantFinding = { + address: string; + role: "created" | "spent"; + utxoType: string; + verified: "matches-chain" | "not-yet-on-chain"; +}; + +export type ManifestReview = { + action: string; + covenants: CovenantFinding[]; + protocol: string; +}; + +export type ReviewRefusal = { reason: string; refused: true }; + +export type ReviewManifestActionResult = ManifestReview | ReviewRefusal; + +export function isRefusal(result: ReviewManifestActionResult): result is ReviewRefusal { + return "refused" in result; +} + +/** + * Establishes what the wallet knows about an action before anyone is asked to approve it. + * + * Runs before the permission gate deliberately: a standing permission skips the prompt, + * so this is the only thing between a request and a signature. Everything it cannot + * establish is a refusal — there is no return value that means "probably fine". + */ +export async function reviewManifestAction( + request: ParsedLiquidProcessCtParams, + input: { + compile: CompileCovenant; + network: string; + readTxOut: ReadTxOut; + }, +): Promise<ReviewManifestActionResult> { + const requirements = resolveActionRequirements(request); + + if (requirements.missing.length > 0) { + const named = requirements.missing + .map((entry) => (entry.keys ? `${entry.reason} (${entry.keys.join(", ")})` : entry.reason)) + .join(" "); + + return { reason: `This request cannot be built. ${named}`, refused: true }; + } + + const action = findAction(request); + + if (!action) { + return { reason: `The manifest declares no action named "${request.action}".`, refused: true }; + } + + const declaredTypes = declaredParamTypes(action); + const covenants: CovenantFinding[] = []; + + for (const site of covenantSites(action)) { + const derived = await deriveCovenantAddress(request, { + compile: input.compile, + declaredTypes, + network: input.network, + utxoType: site.utxoType, + wiring: site.wiring, + }); + + if (!derived.ok) { + return { reason: derived.reason, refused: true }; + } + + if (site.role === "created") { + covenants.push({ + address: derived.derivation.address, + role: "created", + utxoType: site.utxoType, + verified: "not-yet-on-chain", + }); + + continue; + } + + const outpoint = findStateOutpoint(request, site.utxoType); + + if (!outpoint) { + return { + reason: `The state file lists no ${site.utxoType} to spend.`, + refused: true, + }; + } + + let onChain; + + try { + onChain = await input.readTxOut(outpoint); + } catch (error) { + return { + reason: `Could not read what is at ${outpoint.txid}:${outpoint.vout}: ${String(error)}`, + refused: true, + }; + } + + const matched = covenantMatchesChain(derived.derivation, onChain.scriptPubKeyAddress); + + if (!matched.matched) { + return { reason: matched.reason, refused: true }; + } + + covenants.push({ + address: derived.derivation.address, + role: "spent", + utxoType: site.utxoType, + verified: "matches-chain", + }); + } + + return { + action: request.action, + covenants, + protocol: typeof request.manifest.protocol === "string" ? request.manifest.protocol : "", + }; +} + +type CovenantSite = { + role: "created" | "spent"; + utxoType: string; + wiring: Record<string, unknown>; +}; + +/** + * Every place in the action where a covenant appears, and which side it is on. + * + * Inputs spend a covenant, outputs create one. The distinction decides whether there is + * anything on chain to compare the derived address against. + */ +function covenantSites(action: Record<string, unknown>): CovenantSite[] { + const sites: CovenantSite[] = []; + + for (const input of asArray(action.inputs)) { + const site = covenantReference(asRecord(input)?.utxo_source); + + if (site) { + sites.push({ ...site, role: "spent" }); + } + } + + for (const output of asArray(action.outputs)) { + const site = covenantReference(asRecord(output)?.destination); + + if (site) { + sites.push({ ...site, role: "created" }); + } + } + + return sites; +} + +function covenantReference( + value: unknown, +): { utxoType: string; wiring: Record<string, unknown> } | undefined { + const record = asRecord(value); + const utxoType = record?.utxo_type; + + if (typeof utxoType !== "string") { + return undefined; + } + + return { utxoType, wiring: asRecord(record?.compile_params) ?? {} }; +} + +function findStateOutpoint( + request: ParsedLiquidProcessCtParams, + utxoType: string, +): { txid: string; vout: number } | undefined { + for (const entry of asArray(request.state?.utxos)) { + const utxo = asRecord(entry); + + if (utxo?.utxo_type !== utxoType) { + continue; + } + + if (typeof utxo.txid === "string" && typeof utxo.vout === "number") { + return { txid: utxo.txid, vout: utxo.vout }; + } + } + + return undefined; +} + +function findAction(request: ParsedLiquidProcessCtParams): Record<string, unknown> | undefined { + const flat = asRecord(asRecord(request.manifest.actions)?.[request.action]); + + if (flat) { + return flat; + } + + for (const declared of Object.values(asRecord(request.manifest.classes) ?? {})) { + const method = asRecord(asRecord(asRecord(declared)?.methods)?.[request.action]); + + if (method) { + return method; + } + } + + return undefined; +} + +function declaredParamTypes(action: Record<string, unknown>): Record<string, string> { + const types: Record<string, string> = {}; + + for (const [name, declared] of Object.entries(asRecord(action.params) ?? {})) { + const type = asRecord(declared)?.type; + + if (typeof type === "string") { + types[name] = type; + } + } + + return types; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asRecord(value: unknown): Record<string, unknown> | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record<string, unknown>) + : undefined; +} From a7a6a4f6853a7beaea653d3345498815396a7e17 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:14:26 +0300 Subject: [PATCH 015/124] feat(liquid): verify a manifest action's covenants before anyone approves it Replaces the stub's four functions. parse accepts the six-part request; review loads the wasm module, rebuilds every covenant the action touches, and for one being spent compares the derived address against what the chain says is at its outpoint; the confirmation carries what the wallet established rather than what the site claimed. review runs before the permission gate, which is what makes this a control rather than a prompt: a standing permission skips the prompt, and would have skipped the verification with it. The stub's ELIP-1 Wallet ABI framing is gone. The flow sends a manifest. execute still refuses, and now says exactly what is missing rather than that the feature does not exist: the wasm module exposes compilation and address derivation, not transaction assembly or signing. The contract is verified by the time anything asks it to build. --- .../processConfidentialTransaction/index.ts | 96 ++++++++++++++++--- 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index f7add34..37975b9 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -1,34 +1,104 @@ import { createWalletMethod } from "@/core/wallet-methods/createWalletMethod"; -import { WalletRpcNotImplementedError } from "@/core/wallet-rpc/errors"; +import { + WALLET_RPC_ERROR_REASONS, + WalletRpcInvalidParamsError, + WalletRpcNotImplementedError, +} from "@/core/wallet-rpc/errors"; import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; +import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; +import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; import { LIQUID_WALLET_RPC_METHODS } from "../../../domain/LiquidRpc"; +import { createEsploraTxOutReader } from "../../../domain/manifest/chainRead"; +import { + isRefusal, + type ManifestReview, + reviewManifestAction, +} from "../../../domain/manifest/review"; +import type { ParsedLiquidProcessCtParams } from "../../../domain/manifest/types"; +import { parseLiquidProcessCtParams } from "../../../domain/manifest/validation"; + +export type LiquidProcessCtContext = WalletRpcBaseContext & { + chain: LiquidChainRecord; +}; + +/** The network names the SDK understands, keyed by the wallet's own network kind. */ +const SMPLX_NETWORKS: Record<string, string> = { + mainnet: "liquid", + regtest: "elements-regtest", + testnet: "liquid-testnet", +}; /** - * Liquid Wallet ABI confidential transaction processing (ELIP-1, optional and not - * yet implemented). Wrapped as a proper method so it self-registers on the Liquid RPC - * surface and rejects with a not-implemented error when a dapp invokes it. + * Performs one action of a txManifest protocol. The site sends the manifest, the sources + * of the contracts it references, the chosen action and its filled parameters; everything + * else happens inside the extension. + * + * The wallet establishes for itself that each contract is the one the site describes: it + * rebuilds every covenant from source, and for one being spent compares the derived + * address against what the chain says is at that outpoint. A mismatch refuses, and there + * is no way to click through it. + * + * That check lives in `review` deliberately. `review` runs before the permission gate, so + * a standing permission — which skips the prompt entirely — cannot skip the verification + * with it. */ export const processLiquidConfidentialTransaction = createWalletMethod< - null, - WalletRpcBaseContext, - null, + ParsedLiquidProcessCtParams, + LiquidProcessCtContext, + ManifestReview, never >({ - confirmation: () => ({ + confirmation: ({ params, review }) => ({ data: { + action: review.action, + broadcast: params.broadcast, + // Every covenant the wallet rebuilt, with what it established about each. + // `not-yet-on-chain` marks one being created, which there is nothing to compare + // against — it is a different fact, not a weaker form of verified. + covenants: review.covenants, kind: "liquid.processConfidentialTransaction", + protocol: review.protocol, }, - message: "A dapp wants to process a Liquid confidential transaction.", - title: "Process Liquid confidential transaction?", + message: `A site wants to perform "${review.action}" on the ${review.protocol} protocol.`, + title: "Perform a contract action?", }), execute: () => { throw new WalletRpcNotImplementedError( LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - "Liquid Wallet ABI confidential transaction processing is not implemented yet.", + "The contract was verified, but the transaction cannot be built yet: the wasm module exposes compilation and address derivation, not assembly or signing.", ); }, id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - parse: () => null, - review: () => null, + parse: parseLiquidProcessCtParams, + review: async ({ context, params }) => { + const network = SMPLX_NETWORKS[context.chain.settings.network]; + + if (!network) { + throw new WalletRpcInvalidParamsError( + `Contract actions are not supported on ${context.chain.settings.network}.`, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + const smplx = await loadSmplxWasm(); + + const result = await reviewManifestAction(params, { + compile: ({ argumentsJson, network: target, source }) => + new smplx.Contract(source, argumentsJson).covenantAddress(target), + network, + readTxOut: createEsploraTxOutReader(context.chain.settings.backend), + }); + + if (isRefusal(result)) { + throw new WalletRpcInvalidParamsError( + result.reason, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return result; + }, }); From 8b61692e16f4b3d78b166e32dbdcb441ac3101b5 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:16:34 +0300 Subject: [PATCH 016/124] test(liquid): assert the wallet signer derives stable, network-correct values Six assertions over the key material now bound in the wasm module: an address on the network it was built for, a different address for the same mnemonic on a different network, the same values twice from one mnemonic, an x-only key shaped for a covenant parameter, a confidential address that differs from the plain one, and a refusal for an unknown network. The mnemonic is the BIP39 all-abandon test vector, not a wallet's. --- .../adapters/smplx/loadSmplxWasm.test.ts | 53 +++++++++++++++++++ smplx | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index d7dd9df..7d53e67 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -118,3 +118,56 @@ describe("contract parameters", () => { expect(() => contract.commitmentMerkleRoot()).toThrow(); }); }); + +// A BIP39 test vector, not a wallet mnemonic. Its derived values are stable, which is +// what makes them assertable. +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +describe("wallet signer", () => { + test("derives an address for the network it was built for", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + + expect(signer.address()).toMatch(/^tex1/); + signer.free(); + }); + + test("derives a different address on a different network from the same mnemonic", () => { + const testnet = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const mainnet = new bindings.WalletSigner(TEST_MNEMONIC, "liquid"); + + expect(testnet.address()).not.toBe(mainnet.address()); + testnet.free(); + mainnet.free(); + }); + + test("derives the same values twice from the same mnemonic", () => { + const first = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const second = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + + expect(first.schnorrPublicKey()).toBe(second.schnorrPublicKey()); + expect(first.address()).toBe(second.address()); + first.free(); + second.free(); + }); + + test("exposes an x-only key of the right shape for a covenant parameter", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + + expect(signer.schnorrPublicKey()).toMatch(/^[0-9a-f]{64}$/); + signer.free(); + }); + + // The confidential address is what a blinded output pays to; it must differ from the + // unblinded one or blinding is not happening. + test("the confidential address differs from the plain one", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + + expect(signer.confidentialAddress()).not.toBe(signer.address()); + signer.free(); + }); + + test("refuses an unknown network", () => { + expect(() => new bindings.WalletSigner(TEST_MNEMONIC, "not-a-network")).toThrow(); + }); +}); diff --git a/smplx b/smplx index 785de71..3533dbd 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 785de7188a57fa183d8094129ef4bca52ad8b20b +Subproject commit 3533dbd8683fac998201fe8be1ab03000b39bf4d From 439696c1906d61da3cf56b6c0ea6068eef52f998 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:20:08 +0300 Subject: [PATCH 017/124] test(liquid): assert transaction assembly takes what it should and refuses what it cannot Six assertions over the assembly binding: an empty builder, a wallet input taken as an outpoint plus the encoded output it spends, an unblinded output, and refusals for a txid that is not one, an output encoding that will not parse, and an asset id that is not one. Each refusal also asserts nothing was added, so a rejected input cannot leave a half-built transaction behind. Amounts are passed as BigInt because they are u64 in the module. --- .../adapters/smplx/loadSmplxWasm.test.ts | 66 +++++++++++++++++++ smplx | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 7d53e67..4bbbfe6 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -171,3 +171,69 @@ describe("wallet signer", () => { expect(() => new bindings.WalletSigner(TEST_MNEMONIC, "not-a-network")).toThrow(); }); }); + +describe("transaction assembly", () => { + const TXID = "0".repeat(64); + // L-BTC on Liquid testnet. + const ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + // A P2WPKH output of 100_000 sats of the asset above, consensus-encoded. + const TXOUT_HEX = + "01" + + "499a818545f6bae39fc03b637f2a4e1e64e590cac1bc3a6f6d71aa4443654c14" + + "01" + + "00000000000186a0" + + "00" + + "160014" + + "0000000000000000000000000000000000000000"; + + test("starts empty", () => { + const builder = new bindings.TransactionBuilder(); + + expect(builder.inputCount()).toBe(0); + expect(builder.outputCount()).toBe(0); + builder.free(); + }); + + test("takes a wallet input as an outpoint plus the output it spends", () => { + const builder = new bindings.TransactionBuilder(); + + builder.addWalletInput(TXID, 0, TXOUT_HEX); + + expect(builder.inputCount()).toBe(1); + builder.free(); + }); + + // Amounts are u64 in the module, so they cross as BigInt rather than number — the same + // base-unit discipline the wallet already keeps on its own side. + test("takes an unblinded output", () => { + const builder = new bindings.TransactionBuilder(); + + builder.addOutput("0014" + "00".repeat(20), 50_000n, ASSET); + + expect(builder.outputCount()).toBe(1); + builder.free(); + }); + + test("refuses a txid that is not one", () => { + const builder = new bindings.TransactionBuilder(); + + expect(() => builder.addWalletInput("nope", 0, TXOUT_HEX)).toThrow(); + expect(builder.inputCount()).toBe(0); + builder.free(); + }); + + test("refuses an output encoding it cannot parse", () => { + const builder = new bindings.TransactionBuilder(); + + expect(() => builder.addWalletInput(TXID, 0, "abcd")).toThrow(); + builder.free(); + }); + + test("refuses an asset id that is not one", () => { + const builder = new bindings.TransactionBuilder(); + + expect(() => builder.addOutput("0014" + "00".repeat(20), 1n, "not-an-asset")).toThrow(); + expect(builder.outputCount()).toBe(0); + builder.free(); + }); +}); diff --git a/smplx b/smplx index 3533dbd..afdd422 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 3533dbd8683fac998201fe8be1ab03000b39bf4d +Subproject commit afdd422bf48a382dcfeb89c381069fca54a98b90 From 317650e1a710b5afcd81c08c07b749169ab157f2 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:22:28 +0300 Subject: [PATCH 018/124] test(liquid): drive a transaction through blinding, signing and finalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole shape a manifest Pay action produces, asserted in one place: a wallet output funds the transaction, an output pays somewhere, and the module returns a consensus-encoded transaction, a txid and a fee greater than zero. Two refusals beside it: inputs that cannot cover the outputs and the fee, and a change script that will not parse — the second because failing is better than finalising a transaction whose change goes nowhere. This is the first evidence in the initiative that smplx's blinding and signing work under wasm rather than only its compilation. --- .../adapters/smplx/loadSmplxWasm.test.ts | 69 +++++++++++++++++++ smplx | 2 +- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 4bbbfe6..d63a5ce 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -237,3 +237,72 @@ describe("transaction assembly", () => { builder.free(); }); }); + +// The whole Pay shape in one place: a wallet output funds a transaction, an output pays +// somewhere, and the module blinds, signs and finalises it. This is what the manifest +// runtime will drive; asserting it here means a break shows up as a failing test rather +// than as a transaction the network rejects. +describe("finalising a transaction", () => { + const TXID = "1".repeat(64); + // L-BTC on Liquid testnet, the policy asset the fee is paid in. + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + const FEE_RATE = 100; + + /** An explicit Elements output of `sats` of the policy asset, paying to `scriptHex`. */ + function encodeTxOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const value = sats.toString(16).padStart(16, "0"); + const scriptLen = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${scriptLen}${scriptHex}`; + } + + function fundedBuilder(signer: InstanceType<SmplxBindings["WalletSigner"]>, sats: bigint) { + const builder = new bindings.TransactionBuilder(); + + builder.addWalletInput(TXID, 0, encodeTxOut(sats, signer.scriptPubKeyHex())); + + return builder; + } + + test("blinds, signs and finalises, returning a transaction and its fee", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = fundedBuilder(signer, 100_000n); + + builder.addOutput(signer.scriptPubKeyHex(), 50_000n, POLICY_ASSET); + + const signed = signer.finalizeTransaction(builder, FEE_RATE, signer.scriptPubKeyHex()); + + expect(signed.hex).toMatch(/^[0-9a-f]+$/); + expect(signed.txid).toMatch(/^[0-9a-f]{64}$/); + expect(signed.feeSats > 0n).toBe(true); + + signed.free(); + builder.free(); + signer.free(); + }); + + test("refuses when the inputs cannot cover the outputs and the fee", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = fundedBuilder(signer, 1_000n); + + builder.addOutput(signer.scriptPubKeyHex(), 999_999n, POLICY_ASSET); + + expect(() => signer.finalizeTransaction(builder, FEE_RATE, signer.scriptPubKeyHex())).toThrow(); + + builder.free(); + signer.free(); + }); + + test("refuses a change script it cannot parse, rather than sending change nowhere", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = fundedBuilder(signer, 100_000n); + + builder.addOutput(signer.scriptPubKeyHex(), 50_000n, POLICY_ASSET); + + expect(() => signer.finalizeTransaction(builder, FEE_RATE, "not-hex")).toThrow(); + + builder.free(); + signer.free(); + }); +}); diff --git a/smplx b/smplx index afdd422..3b3e8dc 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit afdd422bf48a382dcfeb89c381069fca54a98b90 +Subproject commit 3b3e8dc8c19c7f57f1a8677c5c46edc1db72cef2 From 55ffcf544486b37a14ffd90bb30a9cf0f703f7df Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:25:51 +0300 Subject: [PATCH 019/124] feat(liquid): establish the fee rate, and reach the account mnemonic without keeping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces the signing path needs, both shaped by what must not happen. The fee rate is read from the same endpoint the covenant check reads, and it fails rather than falling back to a default. The fee is the wallet's business, and a default would quietly turn 'we do not know' into 'we are sure' — which is exactly what refusing is for. When the requested confirmation target is absent it takes the nearest slower one, because being wrong towards a longer wait is the safe direction. withAccountMnemonic runs a callback with the account's mnemonic and takes it away again. It is the whole account secret, so it lives for one call in one place, nothing is cached, nothing is returned, and every wasm object that held it on the way is freed on the way out — including when the callback throws. The derivation is LWK's own, unchanged from how accounts resolve everywhere else, so the account model has one place to drift rather than two. Handing the mnemonic to smplx is the accepted debt this change records, not a shortcut; the conditions that should reopen it are recorded with it. --- .../lwk/wallet/withAccountMnemonic.ts | 75 +++++++++++++++++++ .../liquid/domain/manifest/chainRead.test.ts | 46 +++++++++++- .../liquid/domain/manifest/chainRead.ts | 49 ++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/withAccountMnemonic.ts diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/withAccountMnemonic.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/withAccountMnemonic.ts new file mode 100644 index 0000000..22c54a2 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/withAccountMnemonic.ts @@ -0,0 +1,75 @@ +import type { KeySourceId } from "@/core/accounts/application/account-registry/model/identifiers"; +import type { KeyManagerState } from "@/core/key-manager/types"; + +import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; +import { createLwkMnemonicFromSeedMaterial } from "../createLwkMnemonic"; +import { createLwkNetwork } from "../createLwkNetwork"; +import { getLocalRootSeedMaterial, getSeedMaterialForKeySource } from "../getLocalRootSeedMaterial"; +import { loadLwkWasm } from "../loadLwkWasm"; + +export type AccountMnemonicRequest = { + accountGroupIndex?: number; + chain: LiquidChainRecord; + keyManagerState: KeyManagerState; + keySourceId?: KeySourceId; +}; + +/** + * Runs `use` with the account's BIP-39 mnemonic, and takes it away again afterwards. + * + * The mnemonic is the whole account secret. It exists here only for the duration of one + * call, in one place, and every wasm object that held it on the way is freed before this + * returns — including when `use` throws. Nothing is cached and nothing is returned, so + * there is no handle a later caller could reach it through. + * + * The derivation is LWK's, unchanged from how accounts are resolved everywhere else: + * group 0 is the master seed's own mnemonic; group N derives a BIP-85 child at index N. + * Duplicating that math here rather than reusing it would be a second place for the + * account model to drift. + * + * Why this exists at all: smplx signs and blinds from one source, and blinding derives + * from SLIP77 material an extended private key does not carry. Handing over the mnemonic + * is the accepted debt recorded in this change's specification, not a shortcut — and the + * conditions that should reopen it are recorded there too. + */ +export async function withAccountMnemonic<T>( + request: AccountMnemonicRequest, + use: (mnemonic: string) => Promise<T> | T, +): Promise<T> { + const seedMaterial = request.keySourceId + ? getSeedMaterialForKeySource(request.keyManagerState, request.keySourceId) + : getLocalRootSeedMaterial(request.keyManagerState); + + const lwk = await loadLwkWasm(); + const network = createLwkNetwork(lwk, request.chain); + const masterMnemonic = createLwkMnemonicFromSeedMaterial(lwk, seedMaterial); + + let masterSigner: ReturnType<typeof buildSigner> | undefined; + let accountMnemonic: InstanceType<typeof lwk.Mnemonic> | undefined; + + function buildSigner() { + return new lwk.Signer(masterMnemonic, network); + } + + try { + masterSigner = buildSigner(); + + const accountGroupIndex = request.accountGroupIndex ?? 0; + + accountMnemonic = + accountGroupIndex === 0 + ? masterMnemonic + : masterSigner.derive_bip85_mnemonic(accountGroupIndex, 12); + + return await use(accountMnemonic.toString()); + } finally { + masterSigner?.free(); + + if (accountMnemonic && accountMnemonic !== masterMnemonic) { + accountMnemonic.free(); + } + + masterMnemonic.free(); + network.free(); + } +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts index 9ada970..04518d2 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { createEsploraTxOutReader } from "./chainRead"; +import { createEsploraFeeRateReader, createEsploraTxOutReader } from "./chainRead"; // Response shapes are Esplora's documented ones: /tx/:txid returns a transaction whose // vout entries carry scriptpubkey and scriptpubkey_address. @@ -109,3 +109,47 @@ describe("createEsploraTxOutReader", () => { await expect(read({ txid: TXID, vout: 0 })).rejects.toThrow(); }); }); + +describe("createEsploraFeeRateReader", () => { + // Esplora keys fee estimates by confirmation target, in sats per vbyte. + const ESTIMATES = { "1": 2.5, "144": 0.1, "6": 1 }; + + test("returns the requested target, converted to sats per kvb", async () => { + const { fetchImpl } = respondWith(ESTIMATES); + const read = createEsploraFeeRateReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read(6)).resolves.toBe(1000); + }); + + // Being wrong towards a longer wait is the safe direction; being wrong towards a + // shorter one silently overpays. + test("falls back to the nearest slower target when the exact one is absent", async () => { + const { fetchImpl } = respondWith(ESTIMATES); + const read = createEsploraFeeRateReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read(3)).resolves.toBe(1000); + }); + + test("asks the configured endpoint", async () => { + const { calls, fetchImpl } = respondWith(ESTIMATES); + const read = createEsploraFeeRateReader({ url: "https://esplora.example/" }, fetchImpl); + + await read(1); + + expect(calls[0]?.url).toBe("https://esplora.example/fee-estimates"); + }); + + test("fails rather than guessing when the endpoint does not answer", async () => { + const { fetchImpl } = respondWith({}, false, 503); + const read = createEsploraFeeRateReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read(1)).rejects.toThrow(); + }); + + test("fails rather than guessing when no usable estimate comes back", async () => { + const { fetchImpl } = respondWith({ "1": 0 }); + const read = createEsploraFeeRateReader({ url: "https://esplora.example" }, fetchImpl); + + await expect(read(1)).rejects.toThrow(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts index 18184c1..67a0a2c 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts @@ -88,6 +88,55 @@ export function createEsploraTxOutReader( }; } +/** + * Reads a fee rate the wallet is willing to pay, in satoshis per kilo-vbyte. + * + * The fee is the wallet's business, not the requester's: a fee or fee rate arriving in a + * request is ignored, and an action is refused rather than built when no rate can be + * established. Guessing a default here would quietly convert "we do not know" into "we + * are sure", which is the failure this refusal exists to prevent. + */ +export type ReadFeeRate = (targetBlocks: number) => Promise<number>; + +export function createEsploraFeeRateReader( + endpoint: EsploraEndpoint, + fetchImpl: typeof fetch = fetch, +): ReadFeeRate { + const base = endpoint.url.replace(/\/+$/, ""); + + return async (targetBlocks) => { + const response = await fetchImpl(`${base}/fee-estimates`, { + headers: Object.fromEntries((endpoint.headers ?? []).map(({ name, value }) => [name, value])), + }); + + if (!response.ok) { + throw new Error(`Could not read fee estimates: ${response.status}`); + } + + const body: unknown = await response.json(); + + if (!isRecord(body)) { + throw new Error("Fee estimates came back in a shape this wallet does not understand."); + } + + // Esplora keys estimates by confirmation target. Take the requested target, else the + // nearest slower one, since paying for a longer wait than asked is the safe direction + // to be wrong in. + const targets = Object.keys(body) + .map(Number) + .filter((value) => Number.isFinite(value)) + .sort((a, b) => a - b); + const chosen = targets.find((value) => value >= targetBlocks) ?? targets.at(-1); + const satsPerVbyte = chosen === undefined ? undefined : body[String(chosen)]; + + if (typeof satsPerVbyte !== "number" || !(satsPerVbyte > 0)) { + throw new Error("No usable fee estimate was returned."); + } + + return satsPerVbyte * 1000; + }; +} + function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } From 081a72b9db61505128cb229cda61fb2c79954fb1 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:27:10 +0300 Subject: [PATCH 020/124] feat(liquid): work out what an action's outputs actually pay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the action's declared outputs into concrete amounts, so the thinnest real action can be built end to end. Knowingly minimal: it resolves a literal and a `params.` reference and refuses everything else by name. The format's amounts can be arithmetic over other outputs, the fee and chain state, and evaluating those is a dependency graph with a fee re-pass — a later slice's whole subject. This should be deleted when that lands rather than extended one form at a time, which is why it refuses loudly instead of falling through. Amounts are bigint throughout and never become number: a satoshi count above 2^53 is representable in a transaction and not in a double, and the test asserts one survives. Change carries no amount, because change is whatever is left after the fee — and the fee is not known until the transaction has a shape. --- .../liquid/domain/manifest/plan.test.ts | 98 +++++++++++++ .../chains/liquid/domain/manifest/plan.ts | 134 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/plan.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts new file mode 100644 index 0000000..d1444e1 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import { planAction } from "./plan"; +import type { ParsedLiquidProcessCtParams } from "./types"; + +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const MANIFEST = p2pkManifest as unknown as Record<string, unknown>; +const PAY = (MANIFEST.actions as Record<string, Record<string, unknown>>).Pay; + +function request(params: Record<string, unknown>): ParsedLiquidProcessCtParams { + return { + action: "Pay", + broadcast: false, + contractSources: {}, + manifest: MANIFEST, + params, + }; +} + +describe("planAction", () => { + // Pay declares two outputs: the covenant, whose amount is params.amount_sat, and an + // optional change output. + test("resolves the covenant amount from the request's parameters", () => { + const result = planAction(request({ amount_sat: 50_000, pubkey: PUBKEY }), PAY); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.plan.fundingSats).toBe(50_000n); + expect(result.plan.outputs).toContainEqual({ + id: "p2pk_out", + sats: 50_000n, + target: { kind: "covenant", utxoType: "p2pk_output" }, + }); + } + }); + + test("leaves change without an amount, because it is whatever survives the fee", () => { + const result = planAction(request({ amount_sat: 50_000, pubkey: PUBKEY }), PAY); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + const change = result.plan.outputs.find((output) => output.target.kind === "change"); + + expect(change).toBeDefined(); + expect(change?.sats).toBeUndefined(); + } + }); + + // Amounts are base units and must survive past 2^53, which a number cannot. + test("keeps an amount beyond a double's range exact", () => { + const huge = "9007199254740993"; + const result = planAction(request({ amount_sat: huge, pubkey: PUBKEY }), PAY); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.plan.fundingSats).toBe(9_007_199_254_740_993n); + } + }); + + test("refuses an amount it cannot evaluate rather than assuming one", () => { + const result = planAction( + request({ amount_sat: "will_in.amount_sat - fee", pubkey: PUBKEY }), + PAY, + ); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses when the referenced parameter was not supplied", () => { + const result = planAction(request({ pubkey: PUBKEY }), PAY); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses an output that would pay nothing", () => { + const result = planAction(request({ amount_sat: 0, pubkey: PUBKEY }), PAY); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses a destination it does not resolve", () => { + const result = planAction(request({ amount_sat: 1, pubkey: PUBKEY }), { + outputs: [{ amount_sat: 1, destination: { if: "something" }, id: "odd" }], + }); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses an action with no outputs", () => { + const result = planAction(request({ amount_sat: 1, pubkey: PUBKEY }), { outputs: [] }); + + expect(result).toMatchObject({ ok: false }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts new file mode 100644 index 0000000..d89dfb4 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts @@ -0,0 +1,134 @@ +import type { ParsedLiquidProcessCtParams } from "./types"; + +/** + * A concrete amount the wallet worked out for one of the action's outputs. + * + * Amounts stay base units end to end and never become `number`: a satoshi count above + * 2^53 is representable in a transaction and not in a double. + */ +export type PlannedOutput = { + /** The manifest's id for this output, for anything that has to name it. */ + id: string; + /** Absent for change, whose amount is whatever is left after the fee. */ + sats?: bigint; + /** Where it pays: a covenant type the wallet derived, the wallet, or change. */ + target: { kind: "change" } | { kind: "covenant"; utxoType: string } | { kind: "wallet" }; +}; + +export type PlannedSpend = { + /** Base units this action needs the wallet to fund, before the fee. */ + fundingSats: bigint; + outputs: PlannedOutput[]; +}; + +export type PlanResult = { ok: false; reason: string } | { ok: true; plan: PlannedSpend }; + +/** + * Turns the action's declared outputs into concrete amounts. + * + * Knowingly minimal: it resolves a literal and a `params.` reference and refuses + * everything else. The format's amounts can be arithmetic over other outputs, the fee and + * chain state, and evaluating those is a dependency graph with a fee re-pass — a later + * slice's whole subject. This exists so the thinnest real action can be built end to end, + * and it should be deleted when that slice lands rather than extended one form at a time. + */ +export function planAction( + request: ParsedLiquidProcessCtParams, + action: Record<string, unknown>, +): PlanResult { + const outputs: PlannedOutput[] = []; + let fundingSats = 0n; + + for (const declared of asArray(action.outputs)) { + const output = asRecord(declared); + + if (!output) { + continue; + } + + const id = typeof output.id === "string" ? output.id : ""; + const target = resolveTarget(output.destination); + + if (!target) { + return { + ok: false, + reason: `Output ${id || "(unnamed)"} pays somewhere this runtime does not resolve yet.`, + }; + } + + if (target.kind === "change") { + outputs.push({ id, target }); + + continue; + } + + const amount = resolveAmount(request, output.amount_sat); + + if (amount === undefined) { + return { + ok: false, + reason: `Output ${id || "(unnamed)"} has an amount this runtime does not evaluate yet.`, + }; + } + + if (amount <= 0n) { + return { ok: false, reason: `Output ${id || "(unnamed)"} would pay nothing.` }; + } + + fundingSats += amount; + outputs.push({ id, sats: amount, target }); + } + + if (outputs.length === 0) { + return { ok: false, reason: "The action declares no outputs." }; + } + + return { ok: true, plan: { fundingSats, outputs } }; +} + +function resolveTarget(destination: unknown): PlannedOutput["target"] | undefined { + if (destination === "change") { + return { kind: "change" }; + } + + if (destination === "wallet") { + return { kind: "wallet" }; + } + + const utxoType = asRecord(destination)?.utxo_type; + + return typeof utxoType === "string" ? { kind: "covenant", utxoType } : undefined; +} + +/** A literal, or a `params.` reference to one. Anything else is refused by the caller. */ +function resolveAmount(request: ParsedLiquidProcessCtParams, amount: unknown): bigint | undefined { + if (typeof amount === "number" && Number.isSafeInteger(amount)) { + return BigInt(amount); + } + + if (typeof amount === "string") { + const literal = /^\d+$/.test(amount) ? BigInt(amount) : undefined; + + if (literal !== undefined) { + return literal; + } + + const referenced = /^\$?params\.(?<name>[A-Za-z0-9_]+)$/.exec(amount)?.groups?.name; + + return referenced === undefined + ? undefined + : resolveAmount(request, request.params[referenced]); + } + + return undefined; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asRecord(value: unknown): Record<string, unknown> | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record<string, unknown>) + : undefined; +} From 9edbbd47c10160e99c3b63ee63dd27fb6b9b6262 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:29:32 +0300 Subject: [PATCH 021/124] feat(liquid): broadcast a signed transaction, not only a PSET MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest path does not produce a PSET. smplx blinds, signs and finalises internally and hands back a finished transaction, so there was nothing the existing broadcast could take. Adds broadcastTransaction across the same four places the PSET broadcast already lives: the scan core, the client interface, the offscreen client and its protocol, and the offscreen dispatcher. It goes through lwk's Esplora client like every other write, so this does not add a second way to put something on the network — reads were the thing that had to reach the endpoint directly, and writes stay where they were. The dedicated worker rejects it for the same stated reason it rejects the PSET broadcast: LWK's Esplora client needs a window that context does not have. --- .../lwk/sync-worker/createInlineScanClient.ts | 4 ++ .../sync-worker/createOffscreenScanClient.ts | 13 +++++++ .../lwk/sync-worker/createWorkerScanClient.ts | 16 ++++++++ .../lwk/sync-worker/liquidScanCore.ts | 39 +++++++++++++++++++ .../lwk/sync-worker/offscreenProtocol.ts | 13 ++++++- apps/extension/src/offscreen.ts | 6 +++ 6 files changed, 90 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createInlineScanClient.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createInlineScanClient.ts index 2d39cb9..4797ffb 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createInlineScanClient.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createInlineScanClient.ts @@ -1,6 +1,7 @@ import type { SyncWorkerClient } from "./createWorkerScanClient"; import { broadcastPset as runBroadcastPset, + broadcastTransaction as runBroadcastTransaction, readActivity as runReadActivity, scanAndRead as runScanAndRead, scanFresh as runScanFresh, @@ -20,6 +21,9 @@ export function createInlineScanClient(): SyncWorkerClient { async broadcast(input) { return { txid: await runBroadcastPset({ ...input, id: (seq += 1) }) }; }, + async broadcastTransaction(input) { + return { txid: await runBroadcastTransaction({ ...input, id: (seq += 1) }) }; + }, async readActivity(input) { return runReadActivity({ ...input, id: (seq += 1) }); }, diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createOffscreenScanClient.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createOffscreenScanClient.ts index 10f3274..9ba495c 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createOffscreenScanClient.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createOffscreenScanClient.ts @@ -2,6 +2,7 @@ import browser from "webextension-polyfill"; import type { BroadcastInput, + BroadcastTxInput, ReadActivityInput, ScanInput, SyncWorkerClient, @@ -52,6 +53,7 @@ async function ensureOffscreenDocument(offscreen: ChromeOffscreenApi): Promise<v /** A scan/read/broadcast request payload for the offscreen document (the target is added on send). */ type OffscreenRequestPayload = | { input: BroadcastInput; op: "broadcast" } + | { input: BroadcastTxInput; op: "broadcastTransaction" } | { input: ScanInput; op: "scan" | "scanAndRead" } | { input: ReadActivityInput; op: "readActivity" }; @@ -75,6 +77,17 @@ async function requestScan(payload: OffscreenRequestPayload): Promise<OffscreenS */ export function createOffscreenScanClient(): SyncWorkerClient { return { + async broadcastTransaction(input) { + const response = await requestScan({ input, op: "broadcastTransaction" }); + + if (!response.ok) throw new Error(response.error); + + if (response.op !== "broadcastTransaction") { + throw new Error("Unexpected offscreen scan response."); + } + + return { txid: response.txid }; + }, async broadcast(input) { const response = await requestScan({ input, op: "broadcast" }); diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createWorkerScanClient.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createWorkerScanClient.ts index b8e3afe..3bdbea7 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createWorkerScanClient.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createWorkerScanClient.ts @@ -16,6 +16,14 @@ export type ScanAndReadResult = { export type BroadcastInput = { chain: ScanInput["chain"]; psetBase64: string }; export type BroadcastResult = { txid: string }; +/** + * Inputs to broadcast an already-signed, consensus-encoded transaction. + * + * The manifest path produces one of these rather than a PSET: smplx blinds, signs and + * finalises internally and hands back a finished transaction. + */ +export type BroadcastTxInput = { chain: ScanInput["chain"]; txHex: string }; + /** Inputs to read one asset's activity page from the worker's cached wollet. */ export type ReadActivityInput = ScanInput & { cursor: string | null; @@ -33,6 +41,7 @@ type SuccessResponse = Extract<SyncWorkerResponse, { ok: true }>; /** A promise-per-request handle to a scan backend (a dedicated worker, offscreen, or inline). */ export type SyncWorkerClient = { broadcast: (input: BroadcastInput) => Promise<BroadcastResult>; + broadcastTransaction: (input: BroadcastTxInput) => Promise<BroadcastResult>; readActivity: (input: ReadActivityInput) => Promise<ActivityPageResult>; scan: (input: ScanInput) => Promise<ScanResult>; scanAndRead: (input: ScanInput) => Promise<ScanAndReadResult>; @@ -95,6 +104,13 @@ export function createWorkerScanClient(): SyncWorkerClient { } return { + broadcastTransaction() { + // Same reason as `broadcast` below: LWK's Esplora client needs a `window` this + // context does not have. + return Promise.reject( + new Error("The dedicated worker cannot broadcast; use the offscreen or inline client."), + ); + }, broadcast() { // LWK can't run in a dedicated Worker (Esplora's async retry/sleep needs a `window` a // Worker lacks), so this path never broadcasts — the offscreen/inline clients do. Present diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts index ce916ad..f1ecc68 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts @@ -35,6 +35,12 @@ export type LiquidBroadcastInput = { psetBase64: string; }; +export type LiquidBroadcastTxInput = { + chain: LiquidChainRecord; + id: number; + txHex: string; +}; + /** Issued assets get 8 decimals until the registry pass provides their real precision. */ const DEFAULT_ISSUED_ASSET_DECIMALS = 8; @@ -109,6 +115,39 @@ export async function broadcastPset(input: LiquidBroadcastInput): Promise<string return txidString; } +/** + * Broadcast an already-signed, consensus-encoded transaction via the chain's Esplora client, + * returning the resulting txid. + * + * Separate from `broadcastPset` because the manifest path does not produce a PSET: smplx blinds, + * signs and finalises internally and hands back a finished transaction. Both run here rather than + * in the service worker for the same reason — LWK's Esplora client does its async retry/backoff via + * `web_sys::window()`, which the SW lacks. + */ +export async function broadcastTransaction(input: LiquidBroadcastTxInput): Promise<string> { + const lwk = await loadLwkWasm(); + const network = createLwkNetwork(lwk, input.chain); + const client = createLwkBlockchainClient(lwk, input.chain, network); + const transaction = lwk.Transaction.fromString(input.txHex); + + console.warn("[liquid-sync] broadcast tx…", { chainId: input.chain.id, id: input.id }); + const startedAt = Date.now(); + const txid = await client.broadcastTx(transaction); + const txidString = txid.toString(); + + console.warn("[liquid-sync] broadcast tx done", { + id: input.id, + ms: Date.now() - startedAt, + txid: txidString, + }); + + txid.free(); + transaction.free(); + client.free(); + + return txidString; +} + /** Incremental scan on a cached wollet; reads balance and activity directly from it. */ export async function scanAndRead(input: LiquidScanInput): Promise<LiquidWalletSnapshot> { const lwk = await loadLwkWasm(); diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/offscreenProtocol.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/offscreenProtocol.ts index e6a42be..77f543b 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/offscreenProtocol.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/offscreenProtocol.ts @@ -3,7 +3,12 @@ import type { LiquidAssetBalance, LiquidUtxoSnapshot, } from "../../../application/backends/LiquidWalletBackend"; -import type { BroadcastInput, ReadActivityInput, ScanInput } from "./createWorkerScanClient"; +import type { + BroadcastInput, + BroadcastTxInput, + ReadActivityInput, + ScanInput, +} from "./createWorkerScanClient"; /** Discriminator so only the offscreen document (not other extension contexts) handles these. */ export const OFFSCREEN_SCAN_TARGET = "liquid-offscreen-scan"; @@ -14,6 +19,11 @@ export type OffscreenScanMessage = op: "broadcast"; target: typeof OFFSCREEN_SCAN_TARGET; } + | { + input: BroadcastTxInput; + op: "broadcastTransaction"; + target: typeof OFFSCREEN_SCAN_TARGET; + } | { input: ScanInput; op: "scan" | "scanAndRead"; @@ -40,6 +50,7 @@ export type OffscreenScanResponse = op: "readActivity"; } | { ok: true; op: "broadcast"; txid: string } + | { ok: true; op: "broadcastTransaction"; txid: string } | { ok: true; op: "scan"; updateBase64: string | null }; export function isOffscreenScanMessage(value: unknown): value is OffscreenScanMessage { diff --git a/apps/extension/src/offscreen.ts b/apps/extension/src/offscreen.ts index 3a1778f..df9a870 100644 --- a/apps/extension/src/offscreen.ts +++ b/apps/extension/src/offscreen.ts @@ -47,6 +47,12 @@ browser.runtime.onMessage.addListener((message) => { return { ok: true, op: "broadcast", txid }; } + if (message.op === "broadcastTransaction") { + const { txid } = await getScanClient().broadcastTransaction(message.input); + + return { ok: true, op: "broadcastTransaction", txid }; + } + const result = await getScanClient().scanAndRead(message.input); return { ...result, ok: true, op: "scanAndRead" }; From b15c28943e46f5e31aae312db048ae9ba859b2e6 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:30:43 +0300 Subject: [PATCH 022/124] feat(liquid): choose which of the wallet's outputs pay for an action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Largest-first, stopping once the target is covered, which keeps the input count and therefore the fee down. The caller passes headroom for a fee it cannot know exactly yet — the real figure comes from the assembled transaction's weight, and selecting for the outputs alone would leave nothing to pay it with. Selection lives on the wallet's side rather than in the signing module on purpose: the wallet knows which of its outputs it is willing to spend, and a module choosing on its behalf would be making that call somewhere the wallet cannot see it. Amounts are bigint end to end, asserted past 2^53, because a rounded balance is a wrong decision rather than a wrong display. --- .../domain/manifest/coinSelection.test.ts | 68 ++++++++++++++++++ .../liquid/domain/manifest/coinSelection.ts | 70 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.test.ts new file mode 100644 index 0000000..bb507f0 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; + +import { selectCoins, type SelectableUtxo } from "./coinSelection"; + +function utxo(amount: string, overrides: Partial<SelectableUtxo> = {}): SelectableUtxo { + return { + amount, + spendable: true, + txid: amount.padStart(64, "0"), + txOut: "00", + vout: 0, + ...overrides, + }; +} + +describe("selectCoins", () => { + test("covers the target plus the fee headroom", () => { + const result = selectCoins([utxo("30000"), utxo("80000")], 50_000n, 5_000n); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.totalSats).toBeGreaterThanOrEqual(55_000n); + } + }); + + // Fewer inputs is a smaller transaction and therefore a smaller fee. + test("takes the largest first and stops once covered", () => { + const result = selectCoins([utxo("10000"), utxo("90000"), utxo("20000")], 50_000n, 0n); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.selected).toHaveLength(1); + expect(result.selected[0]?.amount).toBe("90000"); + } + }); + + test("refuses when the account cannot cover the fee, even if it covers the outputs", () => { + const result = selectCoins([utxo("50000")], 50_000n, 5_000n); + + expect(result).toMatchObject({ ok: false }); + }); + + test("ignores what the wallet says it cannot spend", () => { + const result = selectCoins([utxo("90000", { spendable: false })], 50_000n, 0n); + + expect(result).toMatchObject({ ok: false }); + }); + + test("refuses to fund nothing", () => { + const result = selectCoins([utxo("90000")], 0n, 0n); + + expect(result).toMatchObject({ ok: false }); + }); + + // Base units past a double's range have to stay exact, or a large balance rounds into a + // wrong decision. + test("keeps amounts beyond a double's range exact", () => { + const result = selectCoins([utxo("9007199254740993")], 9_007_199_254_740_992n, 1n); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.totalSats).toBe(9_007_199_254_740_993n); + } + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts b/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts new file mode 100644 index 0000000..01cbd0f --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts @@ -0,0 +1,70 @@ +/** One wallet output the selector may spend, as the wallet already describes it. */ +export type SelectableUtxo = { + amount: string; + spendable: boolean; + txOut: string; + txid: string; + vout: number; +}; + +export type CoinSelection = + | { ok: false; reason: string } + | { ok: true; selected: SelectableUtxo[]; totalSats: bigint }; + +/** + * Chooses which of the wallet's outputs pay for an action. + * + * Largest-first, which keeps the input count and therefore the fee down, and stops as soon + * as the target is covered. `headroomSats` is what the caller adds for a fee it cannot know + * exactly yet — the final figure comes from the assembled transaction's weight, and + * selecting for the outputs alone would leave nothing to pay it with. + * + * Selection stays here rather than inside the signing module deliberately: the wallet knows + * which of its outputs it is willing to spend, and a module choosing on its behalf would be + * making that decision somewhere the wallet cannot see. + */ +export function selectCoins( + available: SelectableUtxo[], + targetSats: bigint, + headroomSats: bigint, +): CoinSelection { + if (targetSats <= 0n) { + return { ok: false, reason: "Nothing to fund." }; + } + + const needed = targetSats + headroomSats; + const spendable = available + .filter((utxo) => utxo.spendable) + .slice() + .sort((a, b) => (toSats(b.amount) > toSats(a.amount) ? 1 : -1)); + + const selected: SelectableUtxo[] = []; + let totalSats = 0n; + + for (const utxo of spendable) { + if (totalSats >= needed) { + break; + } + + selected.push(utxo); + totalSats += toSats(utxo.amount); + } + + if (totalSats < needed) { + return { + ok: false, + reason: `This account holds ${totalSats} of the ${needed} needed to perform the action and pay its fee.`, + }; + } + + return { ok: true, selected, totalSats }; +} + +/** Amounts arrive as base-unit strings and stay exact; a double would round past 2^53. */ +function toSats(amount: string): bigint { + try { + return BigInt(amount); + } catch { + return 0n; + } +} From 422283f7fec6d19fdc1508969b8a52eddeb5ef11 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:33:19 +0300 Subject: [PATCH 023/124] feat(liquid): build, sign and broadcast a manifest action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the path. execute resolves which account acts, works out what the action's outputs pay, establishes a fee rate from the chain, selects coins to cover both, and hands the whole thing to the signing module — which blinds, signs and finalises. Broadcast happens only when the request asked for it; otherwise the signed transaction comes back unsent. Three things are deliberately where they are. The fee comes from the chain and refuses rather than defaulting, because the request carries none and a default would turn 'we do not know' into 'we are sure'. Coin selection stays on the wallet's side, because the wallet knows which of its outputs it is willing to spend. And the account mnemonic exists for the duration of one call inside withAccountMnemonic, which takes it back afterwards — including when the call throws. An output paying a covenant uses the address the wallet derived in review, not one the request supplied. There is no path from a site-supplied address to a transaction output. The resolved account now carries the BIP-85 index it derives at, which was already an input to resolution and simply never came back out. Without it a caller cannot derive the account's own key material without re-deciding which group it is looking at. --- .../adapters/lwk/wallet/resolveAccount.ts | 1 + .../backends/LiquidWalletBackend.ts | 7 + .../processConfidentialTransaction/index.ts | 193 ++++++++++++++++-- 3 files changed, 187 insertions(+), 14 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/resolveAccount.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/resolveAccount.ts index 20f99c5..1f4818b 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/resolveAccount.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/resolveAccount.ts @@ -69,6 +69,7 @@ export async function createLwkLiquidAccount( // Threaded through so dapp read methods can key the persisted portfolio snapshot; may be // undefined for internal callers that resolve the default account without a group. accountGroupId: input.accountGroupId, + accountGroupIndex, accountIdentifier, chain: input.chain, chainId: input.chain.id, diff --git a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts index 9d6f569..d5c1477 100644 --- a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts +++ b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts @@ -32,6 +32,13 @@ export type LiquidWalletAccount = { * group (the default account) leave it undefined, and the snapshot lookup is simply skipped. */ accountGroupId?: AccountGroupId; + /** + * The BIP-85 index this account's keys derive at, threaded from the resolve input. + * Group 0 is the master seed's own account; group N derives a child mnemonic at N. + * Carried out of resolution so a caller that needs the account's own key material can + * derive it without re-deciding which group it is looking at. + */ + accountGroupIndex?: number; accountIdentifier: string; chain: LiquidChainRecord; chainId: LiquidChainId; diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 37975b9..d621a52 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -1,15 +1,23 @@ +import type { KeyManagerState, UpdateKeyManagerState } from "@/core/key-manager/types"; import { createWalletMethod } from "@/core/wallet-methods/createWalletMethod"; import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError, - WalletRpcNotImplementedError, + WalletRpcResourceUnavailableError, } from "@/core/wallet-rpc/errors"; import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; +import { getSyncWorkerClient } from "../../../adapters/lwk/sync-worker/createSyncWorkerClient"; +import { withAccountMnemonic } from "../../../adapters/lwk/wallet/withAccountMnemonic"; import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; import { LIQUID_WALLET_RPC_METHODS } from "../../../domain/LiquidRpc"; -import { createEsploraTxOutReader } from "../../../domain/manifest/chainRead"; +import { + createEsploraFeeRateReader, + createEsploraTxOutReader, +} from "../../../domain/manifest/chainRead"; +import { selectCoins } from "../../../domain/manifest/coinSelection"; +import { planAction } from "../../../domain/manifest/plan"; import { isRefusal, type ManifestReview, @@ -17,9 +25,21 @@ import { } from "../../../domain/manifest/review"; import type { ParsedLiquidProcessCtParams } from "../../../domain/manifest/types"; import { parseLiquidProcessCtParams } from "../../../domain/manifest/validation"; +import type { LiquidWalletBackend } from "../../backends/LiquidWalletBackend"; +import { resolveDappAccount } from "../../dappAccountScope"; export type LiquidProcessCtContext = WalletRpcBaseContext & { chain: LiquidChainRecord; + keyManagerState: KeyManagerState; + updateKeyManagerState?: UpdateKeyManagerState; + walletBackend: LiquidWalletBackend; +}; + +export type LiquidProcessCtResult = { + broadcast: boolean; + feeSats: string; + transactionHex: string; + txid: string; }; /** The network names the SDK understands, keyed by the wallet's own network kind. */ @@ -29,6 +49,9 @@ const SMPLX_NETWORKS: Record<string, string> = { testnet: "liquid-testnet", }; +/** Confirmation target for the fee estimate, in blocks. */ +const FEE_TARGET_BLOCKS = 6; + /** * Performs one action of a txManifest protocol. The site sends the manifest, the sources * of the contracts it references, the chosen action and its filled parameters; everything @@ -47,7 +70,7 @@ export const processLiquidConfidentialTransaction = createWalletMethod< ParsedLiquidProcessCtParams, LiquidProcessCtContext, ManifestReview, - never + LiquidProcessCtResult >({ confirmation: ({ params, review }) => ({ data: { @@ -63,25 +86,124 @@ export const processLiquidConfidentialTransaction = createWalletMethod< message: `A site wants to perform "${review.action}" on the ${review.protocol} protocol.`, title: "Perform a contract action?", }), - execute: () => { - throw new WalletRpcNotImplementedError( - LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - "The contract was verified, but the transaction cannot be built yet: the wasm module exposes compilation and address derivation, not assembly or signing.", + execute: async ({ context, params, review }) => { + const network = requireNetwork(context); + const account = await resolveDappAccount(context); + const plan = planAction(params, requireAction(params)); + + if (!plan.ok) { + throw new WalletRpcInvalidParamsError( + plan.reason, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + // The fee is the wallet's business: the request carries none, and an action is + // refused rather than built when no rate can be established. + const feeRate = await createEsploraFeeRateReader(context.chain.settings.backend)( + FEE_TARGET_BLOCKS, + ).catch(() => { + throw new WalletRpcResourceUnavailableError( + "The wallet could not establish a fee rate, so it will not build this transaction.", + undefined, + WALLET_RPC_ERROR_REASONS.RESOURCE_UNAVAILABLE, + ); + }); + + await context.walletBackend.syncAccount(account); + + const selection = selectCoins( + context.walletBackend.getUtxos(account, account.rawPolicyAssetId), + plan.plan.fundingSats, + feeHeadroomSats(feeRate), ); - }, - id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - parse: parseLiquidProcessCtParams, - review: async ({ context, params }) => { - const network = SMPLX_NETWORKS[context.chain.settings.network]; - if (!network) { + if (!selection.ok) { throw new WalletRpcInvalidParamsError( - `Contract actions are not supported on ${context.chain.settings.network}.`, + selection.reason, undefined, WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, ); } + const smplx = await loadSmplxWasm(); + const covenantAddresses = new Map( + review.covenants.map((covenant) => [covenant.utxoType, covenant.address]), + ); + + // The mnemonic exists for this call only; `withAccountMnemonic` takes it back. + const signed = await withAccountMnemonic( + { + accountGroupIndex: account.accountGroupIndex, + chain: context.chain, + keyManagerState: context.keyManagerState, + }, + (mnemonic) => { + const signer = new smplx.WalletSigner(mnemonic, network); + const builder = new smplx.TransactionBuilder(); + + try { + for (const utxo of selection.selected) { + builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); + } + + for (const output of plan.plan.outputs) { + if (output.target.kind === "change" || output.sats === undefined) { + continue; + } + + const script = + output.target.kind === "covenant" + ? covenantAddresses.get(output.target.utxoType) + : signer.scriptPubKeyHex(); + + if (!script) { + throw new WalletRpcInvalidParamsError( + `Output ${output.id} pays a covenant the wallet did not verify.`, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + builder.addOutput(script, output.sats, account.rawPolicyAssetId); + } + + const result = signer.finalizeTransaction(builder, feeRate, signer.scriptPubKeyHex()); + const extracted = { + feeSats: result.feeSats.toString(), + transactionHex: result.hex, + txid: result.txid, + }; + + result.free(); + + return extracted; + } finally { + builder.free(); + signer.free(); + } + }, + ); + + if (!params.broadcast) { + return { broadcast: false, ...signed }; + } + + // LWK's Esplora client needs a `window` the service worker does not have, so the + // finished transaction crosses into the offscreen document to go out. Nothing else + // crosses: it is already signed. + const sent = await getSyncWorkerClient().broadcastTransaction({ + chain: account.chain, + txHex: signed.transactionHex, + }); + + return { broadcast: true, ...signed, txid: sent.txid }; + }, + id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, + parse: parseLiquidProcessCtParams, + review: async ({ context, params }) => { + const network = requireNetwork(context); const smplx = await loadSmplxWasm(); const result = await reviewManifestAction(params, { @@ -102,3 +224,46 @@ export const processLiquidConfidentialTransaction = createWalletMethod< return result; }, }); + +function requireNetwork(context: LiquidProcessCtContext): string { + const network = SMPLX_NETWORKS[context.chain.settings.network]; + + if (!network) { + throw new WalletRpcInvalidParamsError( + `Contract actions are not supported on ${context.chain.settings.network}.`, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return network; +} + +function requireAction(params: ParsedLiquidProcessCtParams): Record<string, unknown> { + const actions = params.manifest.actions; + const action = + typeof actions === "object" && actions !== null + ? (actions as Record<string, unknown>)[params.action] + : undefined; + + if (typeof action !== "object" || action === null) { + throw new WalletRpcInvalidParamsError( + `The manifest declares no action named "${params.action}".`, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return action as Record<string, unknown>; +} + +/** + * What to over-select by so the finished transaction can pay its own fee. + * + * The real fee comes from the assembled transaction's weight, which does not exist until + * after selection. A small transaction is on the order of a kilo-vbyte, so one kvb at the + * chosen rate covers it with room to spare, and whatever is left over comes back as change. + */ +function feeHeadroomSats(feeRateSatsPerKvb: number): bigint { + return BigInt(Math.ceil(feeRateSatsPerKvb)); +} From c0f8dbd50aa8beabcb316e40a020a3be6935e1b8 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 03:38:48 +0300 Subject: [PATCH 024/124] refactor(liquid): settle the transaction in review, leave only signing to execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam was recorded this way from the start — review interprets, compiles, verifies and builds; execute signs and optionally broadcasts — and the first implementation put building after the confirmation instead. Moving it back matters for a reason beyond tidiness: what a person is asked to approve should be the transaction that gets signed, not a description of one reassembled afterwards from the same inputs, which might not match. So review now also plans the outputs, establishes the fee rate, and selects the coins, and carries all three into the confirmation. execute rebuilds exactly that and signs it. The wallet's own script comes from its receive address rather than from the signer, so nothing in review touches key material. Deriving a script from an address is public work and should not require a seed. One test narrowed rather than passed: Receive verifies but cannot yet be built, because its output amount references another input and the planner does not evaluate that. It now asserts the refusal is about the amount and not about the covenant, which is what shows verification got past — a weaker claim than before, and the true one. --- .../adapters/lwk/wallet/toScriptPubKeyHex.ts | 25 ++++ .../processConfidentialTransaction/index.ts | 123 +++--------------- .../liquid/domain/manifest/review.test.ts | 54 ++++---- .../chains/liquid/domain/manifest/review.ts | 86 +++++++++++- 4 files changed, 162 insertions(+), 126 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/toScriptPubKeyHex.ts diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/toScriptPubKeyHex.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/toScriptPubKeyHex.ts new file mode 100644 index 0000000..f88579a --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/toScriptPubKeyHex.ts @@ -0,0 +1,25 @@ +import { loadLwkWasm } from "../loadLwkWasm"; + +/** + * The scriptPubKey an address pays to, as lowercase hex. + * + * Exists so a caller that needs a wallet output's script does not have to reach for key + * material to get it. An address is public; deriving a script from one should not require + * touching a seed, and this is what keeps that true. + */ +export async function toScriptPubKeyHex(address: string): Promise<string> { + const lwk = await loadLwkWasm(); + const parsed = new lwk.Address(address); + + try { + const script = parsed.scriptPubkey(); + + try { + return script.toString(); + } finally { + script.free(); + } + } finally { + parsed.free(); + } +} diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index d621a52..9bed4c9 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -1,13 +1,10 @@ import type { KeyManagerState, UpdateKeyManagerState } from "@/core/key-manager/types"; import { createWalletMethod } from "@/core/wallet-methods/createWalletMethod"; -import { - WALLET_RPC_ERROR_REASONS, - WalletRpcInvalidParamsError, - WalletRpcResourceUnavailableError, -} from "@/core/wallet-rpc/errors"; +import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError } from "@/core/wallet-rpc/errors"; import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; import { getSyncWorkerClient } from "../../../adapters/lwk/sync-worker/createSyncWorkerClient"; +import { toScriptPubKeyHex } from "../../../adapters/lwk/wallet/toScriptPubKeyHex"; import { withAccountMnemonic } from "../../../adapters/lwk/wallet/withAccountMnemonic"; import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; @@ -16,8 +13,6 @@ import { createEsploraFeeRateReader, createEsploraTxOutReader, } from "../../../domain/manifest/chainRead"; -import { selectCoins } from "../../../domain/manifest/coinSelection"; -import { planAction } from "../../../domain/manifest/plan"; import { isRefusal, type ManifestReview, @@ -49,9 +44,6 @@ const SMPLX_NETWORKS: Record<string, string> = { testnet: "liquid-testnet", }; -/** Confirmation target for the fee estimate, in blocks. */ -const FEE_TARGET_BLOCKS = 6; - /** * Performs one action of a txManifest protocol. The site sends the manifest, the sources * of the contracts it references, the chosen action and its filled parameters; everything @@ -89,50 +81,11 @@ export const processLiquidConfidentialTransaction = createWalletMethod< execute: async ({ context, params, review }) => { const network = requireNetwork(context); const account = await resolveDappAccount(context); - const plan = planAction(params, requireAction(params)); - - if (!plan.ok) { - throw new WalletRpcInvalidParamsError( - plan.reason, - undefined, - WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, - ); - } - - // The fee is the wallet's business: the request carries none, and an action is - // refused rather than built when no rate can be established. - const feeRate = await createEsploraFeeRateReader(context.chain.settings.backend)( - FEE_TARGET_BLOCKS, - ).catch(() => { - throw new WalletRpcResourceUnavailableError( - "The wallet could not establish a fee rate, so it will not build this transaction.", - undefined, - WALLET_RPC_ERROR_REASONS.RESOURCE_UNAVAILABLE, - ); - }); - - await context.walletBackend.syncAccount(account); - - const selection = selectCoins( - context.walletBackend.getUtxos(account, account.rawPolicyAssetId), - plan.plan.fundingSats, - feeHeadroomSats(feeRate), - ); - - if (!selection.ok) { - throw new WalletRpcInvalidParamsError( - selection.reason, - undefined, - WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, - ); - } - const smplx = await loadSmplxWasm(); - const covenantAddresses = new Map( - review.covenants.map((covenant) => [covenant.utxoType, covenant.address]), - ); - // The mnemonic exists for this call only; `withAccountMnemonic` takes it back. + // Everything except signing was settled in `review`, before the person was asked. + // What gets signed here is the transaction they were shown, not one reassembled + // afterwards from the same inputs. const signed = await withAccountMnemonic( { accountGroupIndex: account.accountGroupIndex, @@ -144,32 +97,19 @@ export const processLiquidConfidentialTransaction = createWalletMethod< const builder = new smplx.TransactionBuilder(); try { - for (const utxo of selection.selected) { + for (const utxo of review.selected) { builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); } - for (const output of plan.plan.outputs) { - if (output.target.kind === "change" || output.sats === undefined) { - continue; - } - - const script = - output.target.kind === "covenant" - ? covenantAddresses.get(output.target.utxoType) - : signer.scriptPubKeyHex(); - - if (!script) { - throw new WalletRpcInvalidParamsError( - `Output ${output.id} pays a covenant the wallet did not verify.`, - undefined, - WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, - ); - } - - builder.addOutput(script, output.sats, account.rawPolicyAssetId); + for (const output of review.outputs) { + builder.addOutput(output.scriptPubKeyHex, output.sats, account.rawPolicyAssetId); } - const result = signer.finalizeTransaction(builder, feeRate, signer.scriptPubKeyHex()); + const result = signer.finalizeTransaction( + builder, + review.feeRateSatsPerKvb, + signer.scriptPubKeyHex(), + ); const extracted = { feeSats: result.feeSats.toString(), transactionHex: result.hex, @@ -204,13 +144,21 @@ export const processLiquidConfidentialTransaction = createWalletMethod< parse: parseLiquidProcessCtParams, review: async ({ context, params }) => { const network = requireNetwork(context); + const account = await resolveDappAccount(context); const smplx = await loadSmplxWasm(); + await context.walletBackend.syncAccount(account); + const result = await reviewManifestAction(params, { compile: ({ argumentsJson, network: target, source }) => new smplx.Contract(source, argumentsJson).covenantAddress(target), + fundingUtxos: context.walletBackend.getUtxos(account, account.rawPolicyAssetId), network, + readFeeRate: createEsploraFeeRateReader(context.chain.settings.backend), readTxOut: createEsploraTxOutReader(context.chain.settings.backend), + walletScriptPubKeyHex: await toScriptPubKeyHex( + context.walletBackend.getReceiveAddress(account).address, + ), }); if (isRefusal(result)) { @@ -238,32 +186,3 @@ function requireNetwork(context: LiquidProcessCtContext): string { return network; } - -function requireAction(params: ParsedLiquidProcessCtParams): Record<string, unknown> { - const actions = params.manifest.actions; - const action = - typeof actions === "object" && actions !== null - ? (actions as Record<string, unknown>)[params.action] - : undefined; - - if (typeof action !== "object" || action === null) { - throw new WalletRpcInvalidParamsError( - `The manifest declares no action named "${params.action}".`, - undefined, - WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, - ); - } - - return action as Record<string, unknown>; -} - -/** - * What to over-select by so the finished transaction can pay its own fee. - * - * The real fee comes from the assembled transaction's weight, which does not exist until - * after selection. A small transaction is on the order of a kilo-vbyte, so one kvb at the - * chosen rate covers it with room to spare, and whatever is left over comes back as change. - */ -function feeHeadroomSats(feeRateSatsPerKvb: number): bigint { - return BigInt(Math.ceil(feeRateSatsPerKvb)); -} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts index c69da3d..758cb29 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -25,6 +25,20 @@ function request( } const compile = () => DERIVED; +const WALLET_SCRIPT = "0014" + "11".repeat(20); +const readFeeRate = async () => 1000; +const fundingUtxos = [ + { amount: "1000000", spendable: true, txid: "c".repeat(64), txOut: "00", vout: 0 }, +]; + +/** The three dependencies every case shares; individual tests override what they exercise. */ +const deps = { + compile, + fundingUtxos, + network: "liquid", + readFeeRate, + walletScriptPubKeyHex: WALLET_SCRIPT, +}; const readTxOut = (address: string) => async () => ({ scriptPubKeyAddress: address, scriptPubKeyHex: "5120aabb", @@ -47,8 +61,7 @@ describe("reviewManifestAction", () => { describe("creating a covenant", () => { test("reports the derived address as not yet on chain", async () => { const result = await reviewManifestAction(request(), { - compile, - network: "liquid", + ...deps, readTxOut: readTxOut("unused"), }); @@ -70,8 +83,7 @@ describe("reviewManifestAction", () => { let asked = 0; await reviewManifestAction(request(), { - compile, - network: "liquid", + ...deps, readTxOut: async () => { asked += 1; @@ -86,27 +98,26 @@ describe("reviewManifestAction", () => { // Receive spends the covenant. This is where the wallet's derivation is checked against // something it did not get from the requester. describe("spending a covenant", () => { - test("passes when the rebuilt contract lands where the funds are", async () => { + // Receive verifies but cannot yet be built: its output amount references another + // input, which the planner does not evaluate. Asserting that the refusal is about the + // amount rather than the covenant is what shows verification got past. + test("gets past verification when the rebuilt contract lands where the funds are", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { - compile, - network: "liquid", + ...deps, readTxOut: readTxOut(DERIVED), }); - expect(isRefusal(result)).toBe(false); + expect(isRefusal(result)).toBe(true); - if (!isRefusal(result)) { - expect(result.covenants[0]).toMatchObject({ - role: "spent", - verified: "matches-chain", - }); + if (isRefusal(result)) { + expect(result.reason).toContain("amount"); + expect(result.reason).not.toContain("rebuilds to"); } }); test("refuses when the funds are somewhere else", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { - compile, - network: "liquid", + ...deps, readTxOut: readTxOut("tex1p_somewhere_else"), }); @@ -115,8 +126,7 @@ describe("reviewManifestAction", () => { test("refuses when the state file lists no such covenant", async () => { const result = await reviewManifestAction(spendRequest({ utxos: [] }), { - compile, - network: "liquid", + ...deps, readTxOut: readTxOut(DERIVED), }); @@ -125,8 +135,7 @@ describe("reviewManifestAction", () => { test("refuses when the chain cannot be read, rather than proceeding unchecked", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { - compile, - network: "liquid", + ...deps, readTxOut: async () => { throw new Error("offline"); }, @@ -138,7 +147,7 @@ describe("reviewManifestAction", () => { test("refuses before reading anything when the state file is absent", async () => { const result = await reviewManifestAction( request({ action: "Receive", params: { pubkey: PUBKEY } }), - { compile, network: "liquid", readTxOut: readTxOut(DERIVED) }, + { ...deps, readTxOut: readTxOut(DERIVED) }, ); expect(isRefusal(result)).toBe(true); @@ -147,8 +156,7 @@ describe("reviewManifestAction", () => { test("refuses a request missing a part the action needs, naming it", async () => { const result = await reviewManifestAction(request({ contractSources: {} }), { - compile, - network: "liquid", + ...deps, readTxOut: readTxOut(DERIVED), }); @@ -161,10 +169,10 @@ describe("reviewManifestAction", () => { test("refuses when the contract does not compile", async () => { const result = await reviewManifestAction(request(), { + ...deps, compile: () => { throw new Error("parse error"); }, - network: "liquid", readTxOut: readTxOut(DERIVED), }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts index dcaded2..b9173b5 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -1,5 +1,7 @@ -import type { ReadTxOut } from "./chainRead"; +import type { ReadFeeRate, ReadTxOut } from "./chainRead"; +import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; import { type CompileCovenant, covenantMatchesChain, deriveCovenantAddress } from "./covenant"; +import { planAction } from "./plan"; import { resolveActionRequirements } from "./requirements"; import type { ParsedLiquidProcessCtParams } from "./types"; @@ -17,10 +19,30 @@ export type CovenantFinding = { verified: "matches-chain" | "not-yet-on-chain"; }; +/** One output of the transaction the wallet worked out, ready to be shown and then built. */ +export type ReviewedOutput = { + id: string; + sats: bigint; + scriptPubKeyHex: string; +}; + +/** + * Everything the wallet established, worked out and decided — before anyone approves it. + * + * The transaction is settled here rather than after the confirmation deliberately: what a + * person is asked to approve should be the transaction that gets signed, not a description + * of one that will be assembled afterwards from the same inputs and might not match. + * Signing is the only thing left for `execute`. + */ export type ManifestReview = { action: string; covenants: CovenantFinding[]; + /** What the wallet will pay, established from the chain rather than from the request. */ + feeRateSatsPerKvb: number; + outputs: ReviewedOutput[]; protocol: string; + /** The wallet's own outputs that fund this, chosen by the wallet. */ + selected: SelectableUtxo[]; }; export type ReviewRefusal = { reason: string; refused: true }; @@ -42,8 +64,12 @@ export async function reviewManifestAction( request: ParsedLiquidProcessCtParams, input: { compile: CompileCovenant; + /** The wallet's spendable outputs, and where its own change and payments go. */ + fundingUtxos: SelectableUtxo[]; network: string; + readFeeRate: ReadFeeRate; readTxOut: ReadTxOut; + walletScriptPubKeyHex: string; }, ): Promise<ReviewManifestActionResult> { const requirements = resolveActionRequirements(request); @@ -123,13 +149,71 @@ export async function reviewManifestAction( }); } + const plan = planAction(request, action); + + if (!plan.ok) { + return { reason: plan.reason, refused: true }; + } + + const covenantScripts = new Map(covenants.map((found) => [found.utxoType, found.address])); + const outputs: ReviewedOutput[] = []; + + for (const planned of plan.plan.outputs) { + if (planned.target.kind === "change" || planned.sats === undefined) { + continue; + } + + // A covenant output pays the address the wallet derived, never one the request + // supplied. There is no path from a site-supplied address to a transaction output. + const scriptPubKeyHex = + planned.target.kind === "covenant" + ? covenantScripts.get(planned.target.utxoType) + : input.walletScriptPubKeyHex; + + if (!scriptPubKeyHex) { + return { + reason: `Output ${planned.id} pays a covenant the wallet did not verify.`, + refused: true, + }; + } + + outputs.push({ id: planned.id, sats: planned.sats, scriptPubKeyHex }); + } + + let feeRateSatsPerKvb: number; + + try { + feeRateSatsPerKvb = await input.readFeeRate(FEE_TARGET_BLOCKS); + } catch (error) { + return { + reason: `The wallet could not establish a fee rate, so it will not build this: ${String(error)}`, + refused: true, + }; + } + + const selection: CoinSelection = selectCoins( + input.fundingUtxos, + plan.plan.fundingSats, + BigInt(Math.ceil(feeRateSatsPerKvb)), + ); + + if (!selection.ok) { + return { reason: selection.reason, refused: true }; + } + return { action: request.action, covenants, + feeRateSatsPerKvb, + outputs, protocol: typeof request.manifest.protocol === "string" ? request.manifest.protocol : "", + selected: selection.selected, }; } +/** Confirmation target for the fee estimate, in blocks. */ +const FEE_TARGET_BLOCKS = 6; + type CovenantSite = { role: "created" | "spent"; utxoType: string; From bed3536d6e73f448b958346943331b607993d263 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 11:29:15 +0300 Subject: [PATCH 025/124] feat(liquid): pay out what a covenant actually holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An action that spends a covenant says what its output pays by referring to the input — `p2pk_in.amount_sat`. That has to resolve against the chain, and it now does: the amount comes from the same read the covenant check already performs, so a request understating what a covenant holds cannot make the wallet pay out less than it should. The planner gains exactly one form for this, `<input_id>.amount_sat`, alongside the literal and the `params.` reference it already had. Everything else is still refused by name. This is what the Receive shape was missing: it verified and was then refused at planning. It now plans, and the test that recorded the limit is replaced by one asserting the amount comes from the chain rather than the requester. --- .../liquid/domain/manifest/plan.test.ts | 26 +++++++++++ .../chains/liquid/domain/manifest/plan.ts | 33 +++++++++++--- .../liquid/domain/manifest/review.test.ts | 45 ++++++++++++++----- .../chains/liquid/domain/manifest/review.ts | 28 +++++++++--- 4 files changed, 107 insertions(+), 25 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts index d1444e1..e8b34d2 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts @@ -96,3 +96,29 @@ describe("planAction", () => { expect(result).toMatchObject({ ok: false }); }); }); + +describe("planAction with resolved inputs", () => { + const RECEIVE = (MANIFEST.actions as Record<string, Record<string, unknown>>).Receive; + + // Receive pays out what the covenant input holds, which the wallet reads from the chain + // rather than being told. + test("resolves an output amount from what an input actually holds", () => { + const result = planAction(request({ pubkey: PUBKEY }), RECEIVE, { p2pk_in: 42_000n }); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.plan.outputs).toContainEqual({ + id: "received_out", + sats: 42_000n, + target: { kind: "wallet" }, + }); + } + }); + + test("refuses when the referenced input was not resolved", () => { + const result = planAction(request({ pubkey: PUBKEY }), RECEIVE, {}); + + expect(result).toMatchObject({ ok: false }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts index d89dfb4..6ce6fd0 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts @@ -35,6 +35,13 @@ export type PlanResult = { ok: false; reason: string } | { ok: true; plan: Plann export function planAction( request: ParsedLiquidProcessCtParams, action: Record<string, unknown>, + /** + * Base units at each input the wallet already resolved, keyed by the manifest's id for + * it. These come from the chain read the covenant check already performs, so an output + * saying "as much as that input holds" resolves against what is actually there rather + * than against a figure the requester supplied. + */ + inputAmounts: Record<string, bigint> = {}, ): PlanResult { const outputs: PlannedOutput[] = []; let fundingSats = 0n; @@ -62,7 +69,7 @@ export function planAction( continue; } - const amount = resolveAmount(request, output.amount_sat); + const amount = resolveAmount(request, output.amount_sat, inputAmounts); if (amount === undefined) { return { @@ -100,8 +107,18 @@ function resolveTarget(destination: unknown): PlannedOutput["target"] | undefine return typeof utxoType === "string" ? { kind: "covenant", utxoType } : undefined; } -/** A literal, or a `params.` reference to one. Anything else is refused by the caller. */ -function resolveAmount(request: ParsedLiquidProcessCtParams, amount: unknown): bigint | undefined { +/** + * A literal, a `params.` reference to one, or `<input_id>.amount_sat`. + * + * The third form is what an action spending a covenant needs — "pay out what that input + * holds" — and it resolves against the chain, not the request. Anything else is refused + * by the caller. + */ +function resolveAmount( + request: ParsedLiquidProcessCtParams, + amount: unknown, + inputAmounts: Record<string, bigint>, +): bigint | undefined { if (typeof amount === "number" && Number.isSafeInteger(amount)) { return BigInt(amount); } @@ -115,9 +132,13 @@ function resolveAmount(request: ParsedLiquidProcessCtParams, amount: unknown): b const referenced = /^\$?params\.(?<name>[A-Za-z0-9_]+)$/.exec(amount)?.groups?.name; - return referenced === undefined - ? undefined - : resolveAmount(request, request.params[referenced]); + if (referenced !== undefined) { + return resolveAmount(request, request.params[referenced], inputAmounts); + } + + const input = /^(?<id>[A-Za-z0-9_]+)\.amount_sat$/.exec(amount)?.groups?.id; + + return input === undefined ? undefined : inputAmounts[input]; } return undefined; diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts index 758cb29..b248f9c 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -39,10 +39,13 @@ const deps = { readFeeRate, walletScriptPubKeyHex: WALLET_SCRIPT, }; -const readTxOut = (address: string) => async () => ({ - scriptPubKeyAddress: address, - scriptPubKeyHex: "5120aabb", -}); +const readTxOut = + (address: string, amountSats = "42000") => + async () => ({ + amountSats, + scriptPubKeyAddress: address, + scriptPubKeyHex: "5120aabb", + }); const spendRequest = (state: unknown) => request({ @@ -98,20 +101,38 @@ describe("reviewManifestAction", () => { // Receive spends the covenant. This is where the wallet's derivation is checked against // something it did not get from the requester. describe("spending a covenant", () => { - // Receive verifies but cannot yet be built: its output amount references another - // input, which the planner does not evaluate. Asserting that the refusal is about the - // amount rather than the covenant is what shows verification got past. - test("gets past verification when the rebuilt contract lands where the funds are", async () => { + test("passes when the rebuilt contract lands where the funds are", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { ...deps, readTxOut: readTxOut(DERIVED), }); - expect(isRefusal(result)).toBe(true); + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.covenants[0]).toMatchObject({ + role: "spent", + verified: "matches-chain", + }); + } + }); - if (isRefusal(result)) { - expect(result.reason).toContain("amount"); - expect(result.reason).not.toContain("rebuilds to"); + // The output pays out what the covenant holds, and what it holds is read from the + // chain — so a request understating the balance cannot make the wallet pay less. + test("pays out the amount the chain reports, not one the request supplied", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + ...deps, + readTxOut: readTxOut(DERIVED, "77000"), + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.outputs).toContainEqual({ + id: "received_out", + sats: 77_000n, + scriptPubKeyHex: WALLET_SCRIPT, + }); } }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts index b9173b5..f8b37b7 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -90,6 +90,8 @@ export async function reviewManifestAction( const declaredTypes = declaredParamTypes(action); const covenants: CovenantFinding[] = []; + /** What each covenant input actually holds, read from the chain rather than told. */ + const inputAmounts: Record<string, bigint> = {}; for (const site of covenantSites(action)) { const derived = await deriveCovenantAddress(request, { @@ -141,6 +143,10 @@ export async function reviewManifestAction( return { reason: matched.reason, refused: true }; } + if (onChain.amountSats !== undefined && site.id) { + inputAmounts[site.id] = BigInt(onChain.amountSats); + } + covenants.push({ address: derived.derivation.address, role: "spent", @@ -149,7 +155,7 @@ export async function reviewManifestAction( }); } - const plan = planAction(request, action); + const plan = planAction(request, action, inputAmounts); if (!plan.ok) { return { reason: plan.reason, refused: true }; @@ -215,6 +221,8 @@ export async function reviewManifestAction( const FEE_TARGET_BLOCKS = 6; type CovenantSite = { + /** The manifest's id for this input or output, which its amounts refer to it by. */ + id: string; role: "created" | "spent"; utxoType: string; wiring: Record<string, unknown>; @@ -229,25 +237,31 @@ type CovenantSite = { function covenantSites(action: Record<string, unknown>): CovenantSite[] { const sites: CovenantSite[] = []; - for (const input of asArray(action.inputs)) { - const site = covenantReference(asRecord(input)?.utxo_source); + for (const entry of asArray(action.inputs)) { + const site = covenantReference(asRecord(entry)?.utxo_source); if (site) { - sites.push({ ...site, role: "spent" }); + sites.push({ ...site, id: identifierOf(entry), role: "spent" }); } } - for (const output of asArray(action.outputs)) { - const site = covenantReference(asRecord(output)?.destination); + for (const entry of asArray(action.outputs)) { + const site = covenantReference(asRecord(entry)?.destination); if (site) { - sites.push({ ...site, role: "created" }); + sites.push({ ...site, id: identifierOf(entry), role: "created" }); } } return sites; } +function identifierOf(entry: unknown): string { + const id = asRecord(entry)?.id; + + return typeof id === "string" ? id : ""; +} + function covenantReference( value: unknown, ): { utxoType: string; wiring: Record<string, unknown> } | undefined { From b98c567872675086aa3eaca0d48f0d4285225cfa Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 11:32:30 +0300 Subject: [PATCH 026/124] test(liquid): measure what a pre-approval dry-run of a signature covenant does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It does not run. p2pk's program asserts a signature, so executing it before the signature exists fails — not because the binding is wrong, but because a program whose only content is 'this signature is valid' has nothing to check yet. Zero witnesses and pruning help a program with branches the spend does not take; they do not help this shape, and this shape is every covenant that authenticates a spender. The test asserts the observed behaviour rather than a hoped-for one, so a future change that makes it run is visible rather than silent. This is the evidence AC-04 needs to be amended against: it asks for a dry-run before the confirmation surface, and for a signature-bearing covenant there is no run to be had at that moment. --- .../adapters/smplx/loadSmplxWasm.test.ts | 86 +++++++++++++++++++ smplx | 2 +- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index d63a5ce..6910d92 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -306,3 +306,89 @@ describe("finalising a transaction", () => { signer.free(); }); }); + +// A covenant input is an output locked by a Simplicity program. The dry-run is what tells +// the wallet the program actually runs against this transaction before anyone approves it. +describe("covenant inputs and the dry-run", () => { + const TXID = "2".repeat(64); + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + const P2PK_SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + const ALICE = "0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const ARGS = JSON.stringify({ PUB_KEY: { type: "Pubkey", value: ALICE } }); + + /** The covenant's own output, so the program is spending exactly what it locks. */ + function covenantTxOut(sats: bigint): string { + const contract = new bindings.Contract(P2PK_SOURCE, ARGS); + const script = contract.scriptPubKeyHex("liquid-testnet"); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const value = sats.toString(16).padStart(16, "0"); + const scriptLen = (script.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${scriptLen}${script}`; + } + + test("takes a covenant input", () => { + const builder = new bindings.TransactionBuilder(); + + builder.addCovenantInput(TXID, 0, covenantTxOut(100_000n), P2PK_SOURCE, ARGS); + + expect(builder.inputCount()).toBe(1); + builder.free(); + }); + + test("refuses a witness set it cannot parse", () => { + const builder = new bindings.TransactionBuilder(); + + expect(() => + builder.addCovenantInput(TXID, 0, covenantTxOut(1n), P2PK_SOURCE, ARGS, "{ not json"), + ).toThrow(); + expect(builder.inputCount()).toBe(0); + builder.free(); + }); + + // The decisive question for a pre-approval dry-run: does a signature-checking covenant + // execute when its signature witness has not been produced yet? + test("records what a zero-witness dry-run of a signature covenant actually does", () => { + const builder = new bindings.TransactionBuilder(); + const contract = new bindings.Contract(P2PK_SOURCE, ARGS); + + builder.addCovenantInput(TXID, 0, covenantTxOut(100_000n), P2PK_SOURCE, ARGS); + builder.addOutput(contract.scriptPubKeyHex("liquid-testnet"), 90_000n, POLICY_ASSET); + + let outcome = "ran"; + + try { + builder.dryRunCovenantInput(0, "liquid-testnet"); + } catch (error) { + outcome = String(error); + } + + // Asserting the observed behaviour rather than a hoped-for one: a program that + // asserts a signature cannot pass before the signature exists. + expect(outcome).not.toBe("ran"); + + builder.free(); + }); + + test("refuses to dry-run an input that is not a covenant", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const walletTxOut = `01${assetLe}0100000000000186a000${"16"}${signer.scriptPubKeyHex()}`; + + builder.addWalletInput(TXID, 0, walletTxOut); + + expect(() => builder.dryRunCovenantInput(0, "liquid-testnet")).toThrow(); + + builder.free(); + signer.free(); + }); + + test("refuses to dry-run an input that does not exist", () => { + const builder = new bindings.TransactionBuilder(); + + expect(() => builder.dryRunCovenantInput(4, "liquid-testnet")).toThrow(); + builder.free(); + }); +}); diff --git a/smplx b/smplx index 3b3e8dc..93f835d 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 3b3e8dc8c19c7f57f1a8677c5c46edc1db72cef2 +Subproject commit 93f835de688532a8760b94844bf3301a925aaf48 From a152ed095f0a73bdedd501aad61f1ecd2529a039 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 11:34:49 +0300 Subject: [PATCH 027/124] build: advance the smplx submodule past covenant inputs and the dry-run --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 93f835d..ca7fef3 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 93f835de688532a8760b94844bf3301a925aaf48 +Subproject commit ca7fef3d969dcdd8cf9e4c5de390dffe9c3d8ced From 29b5d8e18e49a9cccef98cfec58643c32f2e52f9 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 11:52:17 +0300 Subject: [PATCH 028/124] fix(liquid): actually spend the covenant the wallet verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute added only wallet inputs. An action spending a covenant reviewed it, planned against what it holds, and then built a transaction that did not spend it — a silently different transaction from the one that was approved. review now carries each covenant input out with what it was verified against: the outpoint, the re-encoded output, and the source and arguments the derivation actually used. Rebuilding from those rather than resolving the request a second time means the thing spent is the thing checked, by construction rather than by two paths agreeing. Re-encoding is only valid for an explicit output, which is not a limitation here: a covenant output cannot be confidential, because Simplicity's introspection jets cannot read a confidential commitment. One that comes back confidential is refused rather than guessed at, and a test asserts that. Covenant inputs go in before wallet inputs, since the manifest's own input order is what a covenant introspects and the wallet's funding is an addition to it. --- .../processConfidentialTransaction/index.ts | 12 ++++++ .../liquid/domain/manifest/chainRead.ts | 23 +++++++++++ .../liquid/domain/manifest/covenant.test.ts | 7 +++- .../chains/liquid/domain/manifest/covenant.ts | 18 ++++++++- .../liquid/domain/manifest/review.test.ts | 35 +++++++++++++++++ .../chains/liquid/domain/manifest/review.ts | 39 ++++++++++++++++++- 6 files changed, 130 insertions(+), 4 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 9bed4c9..9bd9704 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -97,6 +97,18 @@ export const processLiquidConfidentialTransaction = createWalletMethod< const builder = new smplx.TransactionBuilder(); try { + // Covenant inputs first: the manifest's own input order is what a covenant + // introspects, and wallet inputs are the wallet's addition to it. + for (const covenant of review.covenantInputs) { + builder.addCovenantInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + ); + } + for (const utxo of review.selected) { builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); } diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts index 67a0a2c..b529cab 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts @@ -23,6 +23,29 @@ export type TxOutAtOutPoint = { scriptPubKeyHex: string; }; +/** + * Re-encodes an explicit output so it can be handed to something that spends it. + * + * Only valid for an explicit output, which for this purpose is not a limitation: a + * covenant output is always unblinded, because Simplicity's introspection jets cannot read + * a confidential commitment. A confidential one returns undefined rather than a plausible + * encoding, since guessing here would produce a transaction that fails far away from the + * cause. + */ +export function encodeExplicitTxOut(txOut: TxOutAtOutPoint): string | undefined { + if (txOut.amountSats === undefined || txOut.rawAssetId === undefined) { + return undefined; + } + + // Elements consensus encoding: explicit asset (0x01 + 32 bytes, reversed), explicit + // value (0x01 + 8 bytes big-endian), null nonce, then the script with its length. + const assetLittleEndian = (txOut.rawAssetId.match(/../g) ?? []).reverse().join(""); + const value = BigInt(txOut.amountSats).toString(16).padStart(16, "0"); + const scriptLength = (txOut.scriptPubKeyHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLittleEndian}01${value}00${scriptLength}${txOut.scriptPubKeyHex}`; +} + export type ReadTxOut = (outpoint: OutPoint) => Promise<TxOutAtOutPoint>; export type EsploraEndpoint = { diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts index 9c7509e..384b79a 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts @@ -100,7 +100,12 @@ describe("deriveCovenantAddress", () => { }); describe("covenantMatchesChain", () => { - const derivation = { address: "tex1p_derived", utxoType: "p2pk_output" }; + const derivation = { + address: "tex1p_derived", + argumentsJson: "{}", + source: SOURCE, + utxoType: "p2pk_output", + }; test("matches when the rebuilt contract lands where the funds are", () => { expect(covenantMatchesChain(derivation, "tex1p_derived")).toEqual({ matched: true }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts index ccf6a38..33890c9 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts @@ -16,6 +16,15 @@ export type CompileCovenant = (input: { export type CovenantDerivation = { /** The address the wallet derived by rebuilding the contract itself. */ address: string; + /** + * The parameters it was built with, in the compiler's own shape. + * + * Carried out so anything spending this covenant rebuilds it from exactly what was + * verified, rather than resolving the request a second time and hoping the two agree. + */ + argumentsJson: string; + /** The contract source it was built from. */ + source: string; /** The manifest's name for the kind of UTXO this is. */ utxoType: string; }; @@ -67,14 +76,19 @@ export async function deriveCovenantAddress( return params; } + const argumentsJson = JSON.stringify(params.arguments); + try { const address = await input.compile({ - argumentsJson: JSON.stringify(params.arguments), + argumentsJson, network: input.network, source, }); - return { derivation: { address, utxoType: input.utxoType }, ok: true }; + return { + derivation: { address, argumentsJson, source, utxoType: input.utxoType }, + ok: true, + }; } catch (error) { return { ok: false, diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts index b248f9c..f6486f8 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -39,10 +39,12 @@ const deps = { readFeeRate, walletScriptPubKeyHex: WALLET_SCRIPT, }; +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; const readTxOut = (address: string, amountSats = "42000") => async () => ({ amountSats, + rawAssetId: POLICY_ASSET, scriptPubKeyAddress: address, scriptPubKeyHex: "5120aabb", }); @@ -117,6 +119,39 @@ describe("reviewManifestAction", () => { } }); + // A transaction that verified a covenant and then did not spend it would be a + // silently different transaction from the one reviewed. + test("carries the covenant it verified, ready to be spent", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + ...deps, + readTxOut: readTxOut(DERIVED), + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.covenantInputs).toHaveLength(1); + expect(result.covenantInputs[0]).toMatchObject({ txid: TXID, vout: 0 }); + // The source it carries is the one that was verified, not a second read. + expect(result.covenantInputs[0]?.source).toBe(SOURCE); + } + }); + + // A covenant output cannot be confidential — Simplicity cannot read a confidential + // commitment — so one that comes back without an explicit amount is a refusal rather + // than something to encode a guess for. + test("refuses a covenant output the chain reports as confidential", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + ...deps, + readTxOut: async () => ({ + scriptPubKeyAddress: DERIVED, + scriptPubKeyHex: "5120aabb", + }), + }); + + expect(isRefusal(result)).toBe(true); + }); + // The output pays out what the covenant holds, and what it holds is read from the // chain — so a request understating the balance cannot make the wallet pay less. test("pays out the amount the chain reports, not one the request supplied", async () => { diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts index f8b37b7..c0ce229 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -1,4 +1,4 @@ -import type { ReadFeeRate, ReadTxOut } from "./chainRead"; +import { encodeExplicitTxOut, type ReadFeeRate, type ReadTxOut } from "./chainRead"; import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; import { type CompileCovenant, covenantMatchesChain, deriveCovenantAddress } from "./covenant"; import { planAction } from "./plan"; @@ -19,6 +19,20 @@ export type CovenantFinding = { verified: "matches-chain" | "not-yet-on-chain"; }; +/** + * One covenant the transaction spends, with everything needed to spend it. + * + * The source and arguments are the ones the wallet verified against the chain, not a + * second copy read out of the request again. + */ +export type ReviewedCovenantInput = { + argumentsJson: string; + source: string; + txOutHex: string; + txid: string; + vout: number; +}; + /** One output of the transaction the wallet worked out, ready to be shown and then built. */ export type ReviewedOutput = { id: string; @@ -37,6 +51,8 @@ export type ReviewedOutput = { export type ManifestReview = { action: string; covenants: CovenantFinding[]; + /** The covenant outputs this action spends, ready to be added as inputs. */ + covenantInputs: ReviewedCovenantInput[]; /** What the wallet will pay, established from the chain rather than from the request. */ feeRateSatsPerKvb: number; outputs: ReviewedOutput[]; @@ -92,6 +108,7 @@ export async function reviewManifestAction( const covenants: CovenantFinding[] = []; /** What each covenant input actually holds, read from the chain rather than told. */ const inputAmounts: Record<string, bigint> = {}; + const covenantInputs: ReviewedCovenantInput[] = []; for (const site of covenantSites(action)) { const derived = await deriveCovenantAddress(request, { @@ -147,6 +164,25 @@ export async function reviewManifestAction( inputAmounts[site.id] = BigInt(onChain.amountSats); } + const txOutHex = encodeExplicitTxOut(onChain); + + if (!txOutHex) { + return { + reason: + `The ${site.utxoType} at ${outpoint.txid}:${outpoint.vout} is confidential. ` + + "A covenant output cannot be, because Simplicity cannot read a confidential commitment.", + refused: true, + }; + } + + covenantInputs.push({ + argumentsJson: derived.derivation.argumentsJson, + source: derived.derivation.source, + txOutHex, + txid: outpoint.txid, + vout: outpoint.vout, + }); + covenants.push({ address: derived.derivation.address, role: "spent", @@ -209,6 +245,7 @@ export async function reviewManifestAction( return { action: request.action, + covenantInputs, covenants, feeRateSatsPerKvb, outputs, From d8c3f97d907d68159b1a90d2116276952ee12199 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 12:19:20 +0300 Subject: [PATCH 029/124] test(liquid): drive the whole method, and stop it needing a browser to load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method could only be exercised by building the extension and running it, which is why nothing exercised it. Its outside reaches are now one named object with the extension's wiring as the default, so a test can substitute them. Six assertions over the seam itself: it builds and signs and returns the transaction unsent by default; it broadcasts only when asked and returns the network's txid; it reads the account mnemonic exactly once; it refuses a missing contract source by name; it refuses a malformed request; it refuses an undeclared action. Three of them also assert the mnemonic was never reached, so a refusal cannot quietly touch the seed on its way out. Writing the test surfaced a real defect: importing the module pulled in webextension-polyfill, which throws outside an extension. The sync-worker client is now imported when a transaction is actually broadcast rather than at module load — nothing else in this method needs a browser, and now neither does loading it. --- .../index.test.ts | 168 +++++++++++ .../processConfidentialTransaction/index.ts | 283 ++++++++++-------- 2 files changed, 332 insertions(+), 119 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts new file mode 100644 index 0000000..d98e88a --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; + +import p2pkManifest from "../../../domain/manifest/__fixtures__/p2pk.manifest.json"; +import { + createProcessLiquidConfidentialTransaction, + type LiquidProcessCtContext, + type LiquidProcessCtDependencies, +} from "./index"; + +// Drives the whole seam — parse, verify, plan, sign, broadcast — with substituted +// dependencies. What is asserted is the method's own behaviour: what it refuses, what it +// asks the chain, what it signs, and when it broadcasts. + +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const SOURCE_PATH = "./p2pk.simf"; +const SOURCE = "fn main() { }"; +const DERIVED = "tex1p_derived"; +const WALLET_ADDRESS = "tex1q_wallet"; +const WALLET_SCRIPT = "0014" + "11".repeat(20); +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + +function params(overrides: Record<string, unknown> = {}) { + return { + action: "Pay", + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: p2pkManifest, + params: { amount_sat: 50_000, pubkey: PUBKEY }, + ...overrides, + }; +} + +/** A context with just enough of the wallet for this method to run. */ +function context(): LiquidProcessCtContext { + return { + authorization: { isGranted: () => true }, + chain: { + id: "liquid:testnet", + settings: { backend: { url: "https://esplora.example" }, network: "testnet" }, + }, + keyManagerState: {}, + walletBackend: { + getReceiveAddress: () => ({ address: WALLET_ADDRESS, index: 0 }), + getUtxos: () => [ + { + amount: "1000000", + spendable: true, + txid: "d".repeat(64), + txOut: "00", + vout: 0, + }, + ], + syncAccount: async () => undefined, + }, + } as unknown as LiquidProcessCtContext; +} + +type Recorded = { broadcasts: { txHex: string }[]; mnemonicCalls: number }; + +function dependencies(recorded: Recorded): LiquidProcessCtDependencies { + const signed = { + feeSats: 500n, + free: () => undefined, + hex: "02000000deadbeef", + txid: "e".repeat(64), + }; + + return { + broadcastTransaction: async ({ txHex }) => { + recorded.broadcasts.push({ txHex }); + + return { txid: "f".repeat(64) }; + }, + loadSmplx: async () => + ({ + Contract: class { + covenantAddress() { + return DERIVED; + } + }, + TransactionBuilder: class { + addCovenantInput() {} + addOutput() {} + addWalletInput() {} + free() {} + }, + WalletSigner: class { + finalizeTransaction() { + return signed; + } + free() {} + scriptPubKeyHex() { + return WALLET_SCRIPT; + } + }, + }) as never, + readFeeRate: () => async () => 1000, + readTxOut: () => async () => ({ + amountSats: "42000", + rawAssetId: POLICY_ASSET, + scriptPubKeyAddress: DERIVED, + scriptPubKeyHex: "5120aabb", + }), + resolveAccount: async () => + ({ accountGroupIndex: 0, chain: {}, rawPolicyAssetId: POLICY_ASSET }) as never, + scriptPubKeyHexOf: async () => WALLET_SCRIPT, + withMnemonic: async (_request, use) => { + recorded.mnemonicCalls += 1; + + return use("a test mnemonic"); + }, + }; +} + +function subject() { + const recorded: Recorded = { broadcasts: [], mnemonicCalls: 0 }; + + return { method: createProcessLiquidConfidentialTransaction(dependencies(recorded)), recorded }; +} + +describe("processLiquidConfidentialTransaction", () => { + test("builds and signs, returning the transaction unsent by default", async () => { + const { method, recorded } = subject(); + + const result = await method(params(), context()); + + expect(result).toMatchObject({ broadcast: false, feeSats: "500" }); + expect(result.transactionHex).toBe("02000000deadbeef"); + expect(recorded.broadcasts).toHaveLength(0); + }); + + test("broadcasts only when the request asks, and returns the network's txid", async () => { + const { method, recorded } = subject(); + + const result = await method(params({ broadcast: true }), context()); + + expect(recorded.broadcasts).toEqual([{ txHex: "02000000deadbeef" }]); + expect(result).toMatchObject({ broadcast: true, txid: "f".repeat(64) }); + }); + + // The account secret is reached once, for the signing step, and not before. + test("reads the account mnemonic exactly once", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.mnemonicCalls).toBe(1); + }); + + test("refuses a request missing the contract source, naming it", async () => { + const { method, recorded } = subject(); + + await expect(method(params({ contractSources: {} }), context())).rejects.toThrow(/p2pk\.simf/); + expect(recorded.mnemonicCalls).toBe(0); + }); + + test("refuses a malformed request before reaching the wallet at all", async () => { + const { method, recorded } = subject(); + + await expect(method({ action: "Pay" }, context())).rejects.toThrow(); + expect(recorded.mnemonicCalls).toBe(0); + }); + + test("refuses an action the manifest does not declare", async () => { + const { method } = subject(); + + await expect(method(params({ action: "Withdraw" }), context())).rejects.toThrow(/Withdraw/); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 9bd9704..90f2a3c 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -3,7 +3,6 @@ import { createWalletMethod } from "@/core/wallet-methods/createWalletMethod"; import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError } from "@/core/wallet-rpc/errors"; import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; -import { getSyncWorkerClient } from "../../../adapters/lwk/sync-worker/createSyncWorkerClient"; import { toScriptPubKeyHex } from "../../../adapters/lwk/wallet/toScriptPubKeyHex"; import { withAccountMnemonic } from "../../../adapters/lwk/wallet/withAccountMnemonic"; import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; @@ -12,6 +11,8 @@ import { LIQUID_WALLET_RPC_METHODS } from "../../../domain/LiquidRpc"; import { createEsploraFeeRateReader, createEsploraTxOutReader, + type ReadFeeRate, + type ReadTxOut, } from "../../../domain/manifest/chainRead"; import { isRefusal, @@ -58,132 +59,176 @@ const SMPLX_NETWORKS: Record<string, string> = { * a standing permission — which skips the prompt entirely — cannot skip the verification * with it. */ -export const processLiquidConfidentialTransaction = createWalletMethod< - ParsedLiquidProcessCtParams, - LiquidProcessCtContext, - ManifestReview, - LiquidProcessCtResult ->({ - confirmation: ({ params, review }) => ({ - data: { - action: review.action, - broadcast: params.broadcast, - // Every covenant the wallet rebuilt, with what it established about each. - // `not-yet-on-chain` marks one being created, which there is nothing to compare - // against — it is a different fact, not a weaker form of verified. - covenants: review.covenants, - kind: "liquid.processConfidentialTransaction", - protocol: review.protocol, - }, - message: `A site wants to perform "${review.action}" on the ${review.protocol} protocol.`, - title: "Perform a contract action?", - }), - execute: async ({ context, params, review }) => { - const network = requireNetwork(context); - const account = await resolveDappAccount(context); - const smplx = await loadSmplxWasm(); - - // Everything except signing was settled in `review`, before the person was asked. - // What gets signed here is the transaction they were shown, not one reassembled - // afterwards from the same inputs. - const signed = await withAccountMnemonic( - { - accountGroupIndex: account.accountGroupIndex, - chain: context.chain, - keyManagerState: context.keyManagerState, - }, - (mnemonic) => { - const signer = new smplx.WalletSigner(mnemonic, network); - const builder = new smplx.TransactionBuilder(); - - try { - // Covenant inputs first: the manifest's own input order is what a covenant - // introspects, and wallet inputs are the wallet's addition to it. - for (const covenant of review.covenantInputs) { - builder.addCovenantInput( - covenant.txid, - covenant.vout, - covenant.txOutHex, - covenant.source, - covenant.argumentsJson, - ); - } +/** + * Everything the method reaches outside itself. + * + * Named as one object so the whole seam — parse, verify, plan, sign, broadcast — can be + * driven in a test. Without this the only way to exercise the method is to build the + * extension and run it, which is why nothing did. + */ +export type LiquidProcessCtDependencies = { + broadcastTransaction: (input: { + chain: LiquidChainRecord; + txHex: string; + }) => Promise<{ txid: string }>; + loadSmplx: typeof loadSmplxWasm; + readFeeRate: (chain: LiquidChainRecord) => ReadFeeRate; + readTxOut: (chain: LiquidChainRecord) => ReadTxOut; + resolveAccount: typeof resolveDappAccount; + scriptPubKeyHexOf: (address: string) => Promise<string>; + withMnemonic: typeof withAccountMnemonic; +}; - for (const utxo of review.selected) { - builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); - } +/** How the method is wired in the extension. Tests substitute what they need. */ +export const liquidProcessCtDependencies: LiquidProcessCtDependencies = { + // Imported when a transaction is actually broadcast rather than at module load: the + // sync-worker client reaches for `webextension-polyfill`, which throws outside an + // extension, and nothing else in this method needs a browser. + broadcastTransaction: async (input) => { + const { getSyncWorkerClient } = + await import("../../../adapters/lwk/sync-worker/createSyncWorkerClient"); - for (const output of review.outputs) { - builder.addOutput(output.scriptPubKeyHex, output.sats, account.rawPolicyAssetId); - } + return getSyncWorkerClient().broadcastTransaction(input); + }, + loadSmplx: loadSmplxWasm, + readFeeRate: (chain) => createEsploraFeeRateReader(chain.settings.backend), + readTxOut: (chain) => createEsploraTxOutReader(chain.settings.backend), + resolveAccount: resolveDappAccount, + scriptPubKeyHexOf: toScriptPubKeyHex, + withMnemonic: withAccountMnemonic, +}; - const result = signer.finalizeTransaction( - builder, - review.feeRateSatsPerKvb, - signer.scriptPubKeyHex(), - ); - const extracted = { - feeSats: result.feeSats.toString(), - transactionHex: result.hex, - txid: result.txid, - }; - - result.free(); - - return extracted; - } finally { - builder.free(); - signer.free(); - } +export const createProcessLiquidConfidentialTransaction = ( + dependencies: LiquidProcessCtDependencies = liquidProcessCtDependencies, +) => + createWalletMethod< + ParsedLiquidProcessCtParams, + LiquidProcessCtContext, + ManifestReview, + LiquidProcessCtResult + >({ + confirmation: ({ params, review }) => ({ + data: { + action: review.action, + broadcast: params.broadcast, + // Every covenant the wallet rebuilt, with what it established about each. + // `not-yet-on-chain` marks one being created, which there is nothing to compare + // against — it is a different fact, not a weaker form of verified. + covenants: review.covenants, + kind: "liquid.processConfidentialTransaction", + protocol: review.protocol, }, - ); + message: `A site wants to perform "${review.action}" on the ${review.protocol} protocol.`, + title: "Perform a contract action?", + }), + execute: async ({ context, params, review }) => { + const network = requireNetwork(context); + const account = await dependencies.resolveAccount(context); + const smplx = await dependencies.loadSmplx(); + + // Everything except signing was settled in `review`, before the person was asked. + // What gets signed here is the transaction they were shown, not one reassembled + // afterwards from the same inputs. + const signed = await dependencies.withMnemonic( + { + accountGroupIndex: account.accountGroupIndex, + chain: context.chain, + keyManagerState: context.keyManagerState, + }, + (mnemonic) => { + const signer = new smplx.WalletSigner(mnemonic, network); + const builder = new smplx.TransactionBuilder(); + + try { + // Covenant inputs first: the manifest's own input order is what a covenant + // introspects, and wallet inputs are the wallet's addition to it. + for (const covenant of review.covenantInputs) { + builder.addCovenantInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + ); + } + + for (const utxo of review.selected) { + builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); + } + + for (const output of review.outputs) { + builder.addOutput(output.scriptPubKeyHex, output.sats, account.rawPolicyAssetId); + } + + const result = signer.finalizeTransaction( + builder, + review.feeRateSatsPerKvb, + signer.scriptPubKeyHex(), + ); + const extracted = { + feeSats: result.feeSats.toString(), + transactionHex: result.hex, + txid: result.txid, + }; + + result.free(); + + return extracted; + } finally { + builder.free(); + signer.free(); + } + }, + ); - if (!params.broadcast) { - return { broadcast: false, ...signed }; - } + if (!params.broadcast) { + return { broadcast: false, ...signed }; + } - // LWK's Esplora client needs a `window` the service worker does not have, so the - // finished transaction crosses into the offscreen document to go out. Nothing else - // crosses: it is already signed. - const sent = await getSyncWorkerClient().broadcastTransaction({ - chain: account.chain, - txHex: signed.transactionHex, - }); + // LWK's Esplora client needs a `window` the service worker does not have, so the + // finished transaction crosses into the offscreen document to go out. Nothing else + // crosses: it is already signed. + const sent = await dependencies.broadcastTransaction({ + chain: account.chain, + txHex: signed.transactionHex, + }); - return { broadcast: true, ...signed, txid: sent.txid }; - }, - id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - parse: parseLiquidProcessCtParams, - review: async ({ context, params }) => { - const network = requireNetwork(context); - const account = await resolveDappAccount(context); - const smplx = await loadSmplxWasm(); - - await context.walletBackend.syncAccount(account); - - const result = await reviewManifestAction(params, { - compile: ({ argumentsJson, network: target, source }) => - new smplx.Contract(source, argumentsJson).covenantAddress(target), - fundingUtxos: context.walletBackend.getUtxos(account, account.rawPolicyAssetId), - network, - readFeeRate: createEsploraFeeRateReader(context.chain.settings.backend), - readTxOut: createEsploraTxOutReader(context.chain.settings.backend), - walletScriptPubKeyHex: await toScriptPubKeyHex( - context.walletBackend.getReceiveAddress(account).address, - ), - }); - - if (isRefusal(result)) { - throw new WalletRpcInvalidParamsError( - result.reason, - undefined, - WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, - ); - } + return { broadcast: true, ...signed, txid: sent.txid }; + }, + id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, + parse: parseLiquidProcessCtParams, + review: async ({ context, params }) => { + const network = requireNetwork(context); + const account = await dependencies.resolveAccount(context); + const smplx = await dependencies.loadSmplx(); + + await context.walletBackend.syncAccount(account); + + const result = await reviewManifestAction(params, { + compile: ({ argumentsJson, network: target, source }) => + new smplx.Contract(source, argumentsJson).covenantAddress(target), + fundingUtxos: context.walletBackend.getUtxos(account, account.rawPolicyAssetId), + network, + readFeeRate: dependencies.readFeeRate(context.chain), + readTxOut: dependencies.readTxOut(context.chain), + walletScriptPubKeyHex: await dependencies.scriptPubKeyHexOf( + context.walletBackend.getReceiveAddress(account).address, + ), + }); + + if (isRefusal(result)) { + throw new WalletRpcInvalidParamsError( + result.reason, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return result; + }, + }); - return result; - }, -}); +export const processLiquidConfidentialTransaction = createProcessLiquidConfidentialTransaction(); function requireNetwork(context: LiquidProcessCtContext): string { const network = SMPLX_NETWORKS[context.chain.settings.network]; From 968c5e482660cfca9e2707d0c026db18bf230b79 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 14:13:53 +0300 Subject: [PATCH 030/124] refactor(liquid): keep one copy of the three shape tests a manifest needs A document the wallet did not write arrives as parsed JSON with nothing guaranteed about its interior, so reading it is a sequence of "is this the shape I think it is" questions. Four files had answered them separately. --- .../chains/liquid/domain/manifest/json.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/json.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/json.ts b/apps/extension/src/core/chains/liquid/domain/manifest/json.ts new file mode 100644 index 0000000..108a45a --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/json.ts @@ -0,0 +1,19 @@ +/** + * The three shape tests every part of the runtime needs on a document it did not write. + * + * A manifest arrives as parsed JSON and nothing about its interior is guaranteed, so + * reading it is a sequence of "is this the shape I think it is" questions. These were + * redefined in four files before the runtime core existed; they live here once. + */ + +export function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asRecord(value: unknown): Record<string, unknown> | undefined { + return isRecord(value) ? value : undefined; +} + +export function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} From a04960d68196c26e415e0e040afadd927aee75a7 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 14:13:56 +0300 Subject: [PATCH 031/124] feat(liquid): read every generation of the manifest format as one document The format changes faster than its own specification and carries no field that says which generation a document is from, so this selects by observation: it looks for each legacy spelling where that spelling can appear. Grouped classes and flat actions become one shape that keeps only the difference that matters, which is whether an action has a deployment to read. Every rename is positional. compile_params is both a deprecated reference namespace and the name of the wiring map on a script, an input and an output, so renaming by key alone would rewrite the wiring and change what compiles. --- .../__fixtures__/p2pk-grouped.manifest.json | 138 +++++++ .../liquid/domain/manifest/normalise.test.ts | 189 ++++++++++ .../liquid/domain/manifest/normalise.ts | 354 ++++++++++++++++++ 3 files changed, 681 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk-grouped.manifest.json create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/normalise.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/normalise.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk-grouped.manifest.json b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk-grouped.manifest.json new file mode 100644 index 0000000..7e27ace --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk-grouped.manifest.json @@ -0,0 +1,138 @@ +{ + "compose_version": "0.1.0", + "attestation_version": "1", + "protocol": "p2pk-simplicity", + "description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.", + "chain": "liquid", + "utxo_types": { + "p2pk_output": { + "description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.", + "script": { + "type": "simplicity", + "source": "./p2pk.simf" + }, + "asset": "lbtc", + "confidential": false + } + }, + "classes": { + "P2pk": { + "description": "One pay-to-public-key deployment.", + "methods": { + "Pay": { + "description": "Lock funds into a p2pk output that only PUBKEY's owner can spend.", + "params": { + "pubkey": { + "type": "pubkey", + "description": "The x-only public key that will be able to spend this output (the recipient)." + }, + "amount_sat": { + "type": "u64", + "description": "Amount in satoshis to lock in the output." + } + }, + "inputs": [ + { + "id": "funding_input", + "description": "Wallet UTXO providing the funds.", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { + "min_amount": "params.amount_sat" + } + } + ], + "outputs": [ + { + "id": "p2pk_out", + "description": "The funded p2pk output, locked to PUBKEY.", + "destination": { + "utxo_type": "p2pk_output", + "compile_params": { + "PUB_KEY": "params.pubkey" + } + }, + "amount_sat": "params.amount_sat", + "asset": "lbtc" + }, + { + "id": "change_out", + "description": "Change returned to the funding wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "amount_nonzero", + "rule": { + "type": "arithmetic", + "expr": "params.amount_sat > 0" + }, + "error": { + "code": "INVALID_AMOUNT", + "message": "Amount must be greater than zero" + } + } + ] + }, + "Receive": { + "description": "Spend a p2pk output back into your wallet. Requires a BIP340 signature from the pubkey the output was locked to.", + "params": { + "pubkey": { + "type": "pubkey", + "description": "The x-only public key the output was locked to in Pay. Must be one of your own wallet's keys so the wallet can sign the spend." + } + }, + "inputs": [ + { + "id": "p2pk_in", + "description": "The p2pk covenant UTXO to spend, located via the state file by its utxo_type.", + "utxo_source": { + "utxo_type": "p2pk_output", + "compile_params": { + "PUB_KEY": "params.pubkey" + } + }, + "witnesses": { + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "params.pubkey" + }, + "description": "BIP340 Schnorr signature over the whole transaction, from the recipient key." + } + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO to pay the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true + } + ], + "outputs": [ + { + "id": "received_out", + "description": "The reclaimed funds, sent to your wallet.", + "destination": "wallet", + "asset": "lbtc", + "amount_sat": "p2pk_in.amount_sat" + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + } + } + } + } +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/normalise.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/normalise.test.ts new file mode 100644 index 0000000..a766aed --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/normalise.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; + +import groupedManifest from "./__fixtures__/p2pk-grouped.manifest.json"; +import flatManifest from "./__fixtures__/p2pk.manifest.json"; +import { findAction, normaliseInstance, normaliseManifest } from "./normalise"; + +// The flat fixture is the published p2pk manifest at txmanifest-wallet +// 7d56516a1a1e44a586f25d45a34619c3953758dd, unmodified. The grouped one is the same +// protocol written in the older spellings — grouped classes and `compose_version` — +// and is ours, because no legacy twin of a published manifest exists to compare against. +// What the pair proves is therefore that the two spellings converge, not that any real +// third-party document does. + +const flat = flatManifest as unknown as Record<string, unknown>; +const grouped = groupedManifest as unknown as Record<string, unknown>; + +describe("normaliseManifest", () => { + describe("declaration shapes", () => { + test("reads a flat manifest's actions", () => { + const { manifest } = normaliseManifest(flat); + + expect(manifest.actions.map((action) => action.name)).toEqual(["Pay", "Receive"]); + }); + + test("reads a grouped manifest's methods as the same actions", () => { + const { manifest } = normaliseManifest(grouped); + + expect(manifest.actions.map((action) => action.name)).toEqual(["Pay", "Receive"]); + }); + + test("keeps which class a method belongs to, because only a method reads an instance file", () => { + const { manifest } = normaliseManifest(grouped); + + expect(manifest.actions.map((action) => action.boundTo)).toEqual(["P2pk", "P2pk"]); + }); + + test("a free action is bound to nothing", () => { + const { manifest } = normaliseManifest(flat); + + expect(manifest.actions.every((action) => action.boundTo === undefined)).toBe(true); + }); + + // AC-10, declaration-shape half: equivalent documents in the two shapes produce the + // same action, so nothing downstream can behave differently on one of them. + test("both shapes produce identical actions", () => { + const fromFlat = normaliseManifest(flat).manifest.actions; + const fromGrouped = normaliseManifest(grouped).manifest.actions; + + expect(fromGrouped.map(({ boundTo: _boundTo, ...rest }) => rest)).toEqual( + fromFlat.map(({ boundTo: _boundTo, ...rest }) => rest), + ); + }); + }); + + describe("legacy spellings", () => { + test("accepts compose_version for manifest_version", () => { + expect(normaliseManifest(grouped).manifest.manifestVersion).toBe("0.1.0"); + }); + + test("records what it renamed rather than renaming silently", () => { + const { notes } = normaliseManifest(grouped); + + expect(notes).toContainEqual({ + at: "manifest", + canonical: "manifest_version", + found: "compose_version", + }); + }); + + test("accepts deploy for is_constructor", () => { + const { manifest } = normaliseManifest({ + actions: { Open: { deploy: true } }, + }); + + expect(manifest.actions[0]?.isConstructor).toBe(true); + }); + + test("lifts the legacy hooks block onto the action", () => { + const { manifest } = normaliseManifest({ + actions: { Open: { hooks: { on_validate: "fn main() {}" } } }, + }); + + expect(manifest.actions[0]?.node.on_validate).toBe("fn main() {}"); + expect(manifest.actions[0]?.node.hooks).toBeUndefined(); + }); + + test("flattens the nested compile_params block into params", () => { + const { manifest } = normaliseManifest({ + compile_params: { derived: { COV_HASH: "0x00" }, user_provided: { OWNER: "0x01" } }, + }); + + expect(manifest.params).toEqual({ COV_HASH: "0x00", OWNER: "0x01" }); + }); + + test("accepts lang for compute on a param", () => { + const { manifest } = normaliseManifest({ + actions: { Open: { params: { hash: { lang: "tapleaf", simf: "./a.simf" } } } }, + }); + + const params = manifest.actions[0]?.node.params as Record<string, Record<string, unknown>>; + + expect(params.hash?.compute).toBe("tapleaf"); + expect(params.hash?.lang).toBeUndefined(); + }); + + // The wiring map on a script, an input or an output is also called `compile_params`, + // and it is not the deprecated namespace. Renaming by key alone would rewrite it. + test("leaves a compile_params wiring map alone", () => { + const { manifest } = normaliseManifest({ + actions: { + Open: { + outputs: [ + { destination: { compile_params: { PUB_KEY: "params.pubkey" }, utxo_type: "vault" } }, + ], + }, + }, + }); + + const outputs = manifest.actions[0]?.node.outputs as Record<string, unknown>[]; + const destination = outputs[0]?.destination as Record<string, unknown>; + + expect(destination.compile_params).toEqual({ PUB_KEY: "params.pubkey" }); + }); + }); + + describe("what it does not do", () => { + test("an unknown top-level field survives into the document untouched", () => { + const { manifest } = normaliseManifest({ lifecycle: { stages: ["open"] } }); + + expect(manifest.raw.lifecycle).toEqual({ stages: ["open"] }); + }); + + test("a manifest declaring nothing normalises to an empty document rather than throwing", () => { + const { manifest, notes } = normaliseManifest({}); + + expect(manifest.actions).toEqual([]); + expect(notes).toEqual([]); + }); + }); +}); + +describe("findAction", () => { + test("finds a flat action by name", () => { + expect(findAction(normaliseManifest(flat).manifest, "Receive")?.name).toBe("Receive"); + }); + + test("finds a grouped method by the same name", () => { + expect(findAction(normaliseManifest(grouped).manifest, "Receive")?.boundTo).toBe("P2pk"); + }); + + test("returns nothing for a name the manifest does not declare", () => { + expect(findAction(normaliseManifest(flat).manifest, "Withdraw")).toBeUndefined(); + }); +}); + +describe("normaliseInstance", () => { + test("reads the current shape", () => { + const { instance } = normaliseInstance({ + instance: { class: "Vault", fields: { OWNER: "0x01" } }, + }); + + expect(instance).toEqual({ className: "Vault", fields: { OWNER: "0x01" } }); + }); + + test("accepts the legacy flat instance_params map", () => { + const { instance } = normaliseInstance({ instance_params: { OWNER: "0x01" } }); + + expect(instance.fields).toEqual({ OWNER: "0x01" }); + }); + + test("records that it read the legacy spelling", () => { + const { notes } = normaliseInstance({ instance_params: { OWNER: "0x01" } }); + + expect(notes).toContainEqual({ + at: "instance", + canonical: "instance.fields", + found: "instance_params", + }); + }); + + test("prefers the current spelling when a file carries both", () => { + const { instance } = normaliseInstance({ + instance: { fields: { OWNER: "0x02" } }, + instance_params: { OWNER: "0x01" }, + }); + + expect(instance.fields).toEqual({ OWNER: "0x02" }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/normalise.ts b/apps/extension/src/core/chains/liquid/domain/manifest/normalise.ts new file mode 100644 index 0000000..48b562e --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/normalise.ts @@ -0,0 +1,354 @@ +import { asRecord, isRecord } from "./json"; + +/** + * One spelling the runtime accepted and rewrote, and where it did so. + * + * Kept rather than discarded because a document that needed rewriting is a document from + * an older generation of the format, and that is worth being able to say out loud — both + * to the person approving an action and to whoever reads a refusal later. + */ +export type NormalisationNote = { + /** Where the rename happened, in the document's own terms. */ + at: string; + /** The name the document now carries. */ + canonical: string; + /** The spelling that was found. */ + found: string; +}; + +/** + * One action, however the manifest chose to declare it. + * + * `boundTo` is the whole of the difference between the two declaration shapes: a method + * belongs to a class and therefore to a deployment, and reads that deployment's field + * values; a free action belongs to nothing and reads no instance file. Everything else + * about the two is the same, which is why they normalise to one type rather than two. + */ +export type NormalisedAction = { + /** The class this method belongs to; absent for a free action. */ + boundTo?: string; + isConstructor: boolean; + name: string; + /** The action's own record, with the legacy spellings already rewritten. */ + node: Record<string, unknown>; +}; + +export type NormalisedManifest = { + actions: NormalisedAction[]; + chain?: string; + description?: string; + manifestVersion?: string; + /** + * The document's top level with its legacy spellings rewritten. + * + * Anything reading the manifest generically — the construct registry above all — must + * read this rather than `raw`, or it sees a rewritten field as an unrecognised one. + */ + node: Record<string, unknown>; + /** Protocol-level compile parameters, flattened out of the legacy nested block. */ + params: Record<string, unknown>; + protocol?: string; + /** The document exactly as it arrived, so nothing this layer does not model is lost. */ + raw: Record<string, unknown>; + utxoTypes: Record<string, unknown>; +}; + +export type NormaliseManifestResult = { + manifest: NormalisedManifest; + notes: NormalisationNote[]; +}; + +/** + * Rewrites a manifest's known spellings into one canonical vocabulary. + * + * The format has changed faster than its own specification, so a real document may be + * written in any of several generations and there is no field that reliably says which: + * `manifest_version` tracks the protocol's version, not the format's. So this selects by + * observation — it looks for each legacy spelling where that spelling can appear — rather + * than by branching on a declared generation. + * + * **Every rename is positional.** `compile_params` is both a deprecated reference + * namespace and the name of the wiring map on a script, an input and an output; renaming + * by key alone would rewrite the wiring and change what gets compiled. So this rewrites + * keys only at the paths where the legacy meaning applies, and the namespace — which is a + * spelling inside a reference string rather than a key — is canonicalised where references + * are resolved instead. + * + * Nothing here refuses. An unknown construct survives untouched into `raw`; classifying it + * belongs to the construct registry and refusing on it to a later slice. + */ +export function normaliseManifest(raw: Record<string, unknown>): NormaliseManifestResult { + const notes: NormalisationNote[] = []; + + const manifestVersion = pick(raw, "manifest_version", "compose_version", "manifest", notes); + const params = normaliseProtocolParams(raw, notes); + const actions = normaliseActions(raw, notes); + + const node = { ...raw }; + + delete node.compose_version; + delete node.compile_params; + + if (manifestVersion !== undefined) { + node.manifest_version = manifestVersion; + } + + if (Object.keys(params).length > 0 || "params" in raw) { + node.params = params; + } + + return { + manifest: { + actions, + chain: asString(raw.chain), + description: asString(raw.description), + manifestVersion: asString(manifestVersion), + node, + params, + protocol: asString(raw.protocol), + raw, + utxoTypes: asRecord(raw.utxo_types) ?? {}, + }, + notes, + }; +} + +/** The action of that name, whichever shape declared it. */ +export function findAction( + manifest: NormalisedManifest, + name: string, +): NormalisedAction | undefined { + return manifest.actions.find((action) => action.name === name); +} + +/** One deployment's field values, as the runtime reads them. */ +export type NormalisedInstance = { + className?: string; + fields: Record<string, unknown>; +}; + +export type NormaliseInstanceResult = { + instance: NormalisedInstance; + notes: NormalisationNote[]; +}; + +/** + * Reads a deployment's field values under either spelling. + * + * The current shape nests them under `instance.fields`; the legacy one is a flat + * `instance_params` map beside it. A file carrying both is not a conflict to resolve by + * merging — the nested form is the one a current tool writes, so it wins outright and the + * legacy map is ignored rather than layered underneath. + */ +export function normaliseInstance( + raw: Record<string, unknown> | undefined, +): NormaliseInstanceResult { + const notes: NormalisationNote[] = []; + + if (!raw) { + return { instance: { fields: {} }, notes }; + } + + const nested = asRecord(raw.instance); + const fields = asRecord(nested?.fields); + const legacy = asRecord(raw.instance_params); + + if (!fields && legacy) { + notes.push({ at: "instance", canonical: "instance.fields", found: "instance_params" }); + } + + return { + instance: { + className: asString(nested?.class), + fields: fields ?? legacy ?? {}, + }, + notes, + }; +} + +/** + * Protocol-level compile parameters. + * + * The current spelling is a flat `params` map. The legacy one splits the same values into + * `compile_params.user_provided` and `compile_params.derived`, a distinction about where a + * value came from rather than about what it is, and nothing downstream reads it — so the + * two halves flatten into one map. + */ +function normaliseProtocolParams( + raw: Record<string, unknown>, + notes: NormalisationNote[], +): Record<string, unknown> { + const current = asRecord(raw.params); + const legacy = asRecord(raw.compile_params); + + if (!legacy) { + return current ?? {}; + } + + notes.push({ at: "manifest", canonical: "params", found: "compile_params" }); + + return { + ...asRecord(legacy.derived), + ...asRecord(legacy.user_provided), + ...current, + }; +} + +/** + * Both declaration shapes, in declaration order: flat `actions` first, then each class's + * `methods`. A name declared in both resolves to the flat one, which is what every reader + * of this manifest did before the two shapes were unified. + */ +function normaliseActions( + raw: Record<string, unknown>, + notes: NormalisationNote[], +): NormalisedAction[] { + const actions: NormalisedAction[] = []; + const seen = new Set<string>(); + + for (const [name, declared] of Object.entries(asRecord(raw.actions) ?? {})) { + const node = asRecord(declared); + + if (!node) { + continue; + } + + seen.add(name); + actions.push(normaliseAction(name, node, undefined, notes)); + } + + for (const [className, declared] of Object.entries(asRecord(raw.classes) ?? {})) { + for (const [name, method] of Object.entries(asRecord(asRecord(declared)?.methods) ?? {})) { + const node = asRecord(method); + + if (!node || seen.has(name)) { + continue; + } + + seen.add(name); + actions.push(normaliseAction(name, node, className, notes)); + } + } + + return actions; +} + +function normaliseAction( + name: string, + declared: Record<string, unknown>, + boundTo: string | undefined, + notes: NormalisationNote[], +): NormalisedAction { + const node = { ...declared }; + const at = `action ${name}`; + + const isConstructor = pick(node, "is_constructor", "deploy", at, notes); + + delete node.deploy; + node.is_constructor = isConstructor === undefined ? undefined : Boolean(isConstructor); + + if (node.is_constructor === undefined) { + delete node.is_constructor; + } + + liftHooks(node, at, notes); + node.params = normaliseParamDefinitions(node.params, at, notes); + + if (node.params === undefined) { + delete node.params; + } + + return { + ...(boundTo === undefined ? {} : { boundTo }), + isConstructor: Boolean(isConstructor), + name, + node, + }; +} + +/** + * The legacy `hooks` block held `on_input_resolved` and `on_validate`; both later moved to + * the action itself. Lifting keeps one place to look, and an action already carrying the + * current spelling keeps it — the newer field is not overwritten by an older copy. + */ +function liftHooks(node: Record<string, unknown>, at: string, notes: NormalisationNote[]): void { + const hooks = asRecord(node.hooks); + + if (!hooks) { + return; + } + + for (const key of ["on_input_resolved", "on_validate"] as const) { + if (!(key in hooks) || key in node) { + continue; + } + + node[key] = hooks[key]; + notes.push({ at, canonical: key, found: `hooks.${key}` }); + } + + delete node.hooks; +} + +/** `lang` was the earlier name for a computed parameter's kind; `compute` is current. */ +function normaliseParamDefinitions( + declared: unknown, + at: string, + notes: NormalisationNote[], +): Record<string, unknown> | undefined { + const params = asRecord(declared); + + if (!params) { + return undefined; + } + + const normalised: Record<string, unknown> = {}; + + for (const [name, definition] of Object.entries(params)) { + const record = asRecord(definition); + + if (!record || !("lang" in record) || "compute" in record) { + normalised[name] = definition; + + continue; + } + + const { lang, ...rest } = record; + + normalised[name] = { ...rest, compute: lang }; + notes.push({ at: `${at} param ${name}`, canonical: "compute", found: "lang" }); + } + + return normalised; +} + +/** + * The value under the current name, or under the legacy one — recording which was found so + * a rewrite is never silent. + */ +function pick( + node: Record<string, unknown>, + canonical: string, + legacy: string, + at: string, + notes: NormalisationNote[], +): unknown { + if (canonical in node) { + return node[canonical]; + } + + if (!(legacy in node)) { + return undefined; + } + + notes.push({ at, canonical, found: legacy }); + + return node[legacy]; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** Re-exported so a caller reading a normalised document needs one import, not two. */ +export { asRecord, isRecord }; From f0c96b34338aaa5d078b192f9e75b12d77c2e80e Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 14:14:11 +0300 Subject: [PATCH 032/124] refactor(liquid): answer "where are the covenants" in one place Inputs spend a covenant and outputs create one, and the distinction decides whether there is anything on chain to compare a derived address against. Two functions were about to need the same enumeration. --- .../chains/liquid/domain/manifest/sites.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/sites.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/sites.ts b/apps/extension/src/core/chains/liquid/domain/manifest/sites.ts new file mode 100644 index 0000000..b1e074f --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/sites.ts @@ -0,0 +1,65 @@ +import { asArray, asRecord } from "./json"; +import type { NormalisedAction } from "./normalise"; + +/** + * One place in an action where a covenant appears, and which side it is on. + * + * Inputs spend a covenant, outputs create one, and the distinction decides whether there + * is anything on chain to compare a derived address against. Enumerating both from one + * place is what stops "where are the covenants" being answered differently by whichever + * function happens to be asking. + */ +export type CovenantSite = { + /** The manifest's id for this input or output, which its amounts refer to it by. */ + id: string; + role: "created" | "spent"; + utxoType: string; + /** The compile parameters wired in at this site, unresolved. */ + wiring: Record<string, unknown>; +}; + +export function covenantSites(action: NormalisedAction): CovenantSite[] { + const sites: CovenantSite[] = []; + + for (const entry of asArray(action.node.inputs)) { + const site = covenantReference(asRecord(entry)?.utxo_source); + + if (site) { + sites.push({ ...site, id: identifierOf(entry), role: "spent" }); + } + } + + for (const entry of asArray(action.node.outputs)) { + const site = covenantReference(asRecord(entry)?.destination); + + if (site) { + sites.push({ ...site, id: identifierOf(entry), role: "created" }); + } + } + + return sites; +} + +/** The utxo types this action reaches, in the order it names them. */ +export function namedUtxoTypes(action: NormalisedAction): string[] { + return [...new Set(covenantSites(action).map((site) => site.utxoType))]; +} + +function identifierOf(entry: unknown): string { + const id = asRecord(entry)?.id; + + return typeof id === "string" ? id : ""; +} + +function covenantReference( + value: unknown, +): { utxoType: string; wiring: Record<string, unknown> } | undefined { + const record = asRecord(value); + const utxoType = record?.utxo_type; + + if (typeof utxoType !== "string") { + return undefined; + } + + return { utxoType, wiring: asRecord(record?.compile_params) ?? {} }; +} From 9439fd25a96225964155ed195813cc5d9c9d7a2f Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 14:14:13 +0300 Subject: [PATCH 033/124] feat(liquid): resolve a reference by where it sits, not by what it looks like The format writes six different lookups as strings, and the same text is a legitimate compile parameter in one position and nonsense in another. Listing the accepted forms per position makes the difference unrepresentable rather than a mistake to be caught downstream: a compile parameter referencing the fee is circular, because the fee comes from the transaction that pays to the address the parameter decides. Nothing about how a lookup was spelled travels on its value. Two documents writing one lookup in two accepted spellings have to be indistinguishable to everything downstream, so a deprecated namespace is recorded on the notes channel instead, where it informs a reader without changing a number. --- .../liquid/domain/manifest/references.test.ts | 212 ++++++++++ .../liquid/domain/manifest/references.ts | 396 ++++++++++++++++++ 2 files changed, 608 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/references.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/references.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/references.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/references.test.ts new file mode 100644 index 0000000..5eae540 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/references.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test"; + +import { parseReference, type ReferenceScope, resolveReference } from "./references"; + +// The forms and their order come from the reference implementation's own resolver, as +// recorded in the change bundle's construct inventory: fee, then instance. (with +// compile_params. as a deprecated alias), params., args., a bare unprefixed name tried as +// param then arg, then input_id.amount_sat / .asset and other per-input attributes. + +const SCOPE: ReferenceScope = { + args: { note: "from args" }, + fee: 500n, + inputs: { p2pk_in: { amount_sat: 21_000n, asset: "lbtc" } }, + instance: { OWNER: "0x01", shared: "from instance" }, + params: { pubkey: "0x02", shared: "from params" }, +}; + +describe("parseReference", () => { + test("reads a namespaced reference", () => { + expect(parseReference("params.pubkey")).toMatchObject({ form: "params", name: "pubkey" }); + }); + + test("accepts the $-prefixed spelling of the same reference", () => { + expect(parseReference("$params.pubkey")).toMatchObject({ form: "params", name: "pubkey" }); + }); + + test("reads compile_params. as the deprecated spelling of instance.", () => { + expect(parseReference("compile_params.OWNER")).toMatchObject({ + deprecated: true, + form: "instance", + name: "OWNER", + }); + }); + + test("reads the reserved fee keyword", () => { + expect(parseReference("fee")).toMatchObject({ form: "fee" }); + }); + + test("reads a bare unprefixed name", () => { + expect(parseReference("pubkey")).toMatchObject({ form: "bare", name: "pubkey" }); + }); + + test("reads an attribute of a named input", () => { + expect(parseReference("p2pk_in.amount_sat")).toMatchObject({ + attribute: "amount_sat", + form: "input-attribute", + name: "p2pk_in", + }); + }); + + test("is not an expression parser", () => { + expect(parseReference("params.amount_sat > 0")).toBeUndefined(); + expect(parseReference("a + b")).toBeUndefined(); + }); +}); + +describe("resolveReference", () => { + describe("a site decides which forms it accepts", () => { + test("a compile parameter may come from the request's parameters", () => { + expect(resolveReference("params.pubkey", "compileParam", SCOPE)).toEqual({ + form: "params", + ok: true, + value: "0x02", + }); + }); + + // A covenant address cannot depend on the fee: the fee depends on the transaction, + // which depends on the address. The site is what makes that unrepresentable rather + // than a mistake caught later. + test("a compile parameter may not come from the fee", () => { + const result = resolveReference("fee", "compileParam", SCOPE); + + expect(result.ok).toBe(false); + }); + + test("an output amount may come from the fee", () => { + expect(resolveReference("fee", "amount", SCOPE)).toEqual({ + form: "fee", + ok: true, + value: 500n, + }); + }); + + test("an output amount may come from what an input holds", () => { + expect(resolveReference("p2pk_in.amount_sat", "amount", SCOPE)).toEqual({ + form: "input-attribute", + ok: true, + value: 21_000n, + }); + }); + + test("a compile parameter may not come from what an input holds", () => { + expect(resolveReference("p2pk_in.amount_sat", "compileParam", SCOPE).ok).toBe(false); + }); + + test("a destination names a parameter and nothing else", () => { + expect(resolveReference("params.pubkey", "destination", SCOPE).ok).toBe(true); + expect(resolveReference("fee", "destination", SCOPE).ok).toBe(false); + expect(resolveReference("pubkey", "destination", SCOPE).ok).toBe(false); + }); + + test("says which site refused it, so a refusal can be read", () => { + const result = resolveReference("fee", "compileParam", SCOPE); + + expect(result.ok ? "" : result.reason).toContain("fee"); + }); + }); + + // AC-10, reference-namespace half: the two namespaces and the two prefix spellings are + // four ways of writing one thing, and they produce one result. + describe("equivalent spellings produce identical results", () => { + test("instance. and compile_params. resolve the same value by the same form", () => { + const current = resolveReference("instance.OWNER", "compileParam", SCOPE); + const deprecated = resolveReference("compile_params.OWNER", "compileParam", SCOPE); + + expect(deprecated).toEqual(current); + }); + + test("the $ prefix changes nothing", () => { + expect(resolveReference("$instance.OWNER", "compileParam", SCOPE)).toEqual( + resolveReference("instance.OWNER", "compileParam", SCOPE), + ); + expect(resolveReference("$params.pubkey", "compileParam", SCOPE)).toEqual( + resolveReference("params.pubkey", "compileParam", SCOPE), + ); + }); + + test("all four spellings of one instance field agree", () => { + const results = [ + "instance.OWNER", + "$instance.OWNER", + "compile_params.OWNER", + "$compile_params.OWNER", + ].map((text) => resolveReference(text, "compileParam", SCOPE)); + + expect(new Set(results.map((result) => JSON.stringify(result))).size).toBe(1); + }); + + // Identical results, and still not silent about which generation the document is + // from: the deprecation is recorded beside the normalisation layer's key renames, + // where it informs a reader without changing a value. + test("the deprecated namespace is recorded on the notes channel, not in the result", () => { + const notes: Parameters<typeof resolveReference>[3] = []; + + resolveReference("compile_params.OWNER", "compileParam", SCOPE, notes); + + expect(notes).toContainEqual({ + at: "a compile parameter", + canonical: "instance.", + found: "compile_params.", + }); + }); + + test("the current namespace records nothing", () => { + const notes: Parameters<typeof resolveReference>[3] = []; + + resolveReference("instance.OWNER", "compileParam", SCOPE, notes); + + expect(notes).toEqual([]); + }); + }); + + describe("a bare name is ambiguous by design", () => { + test("resolves as a parameter first", () => { + expect(resolveReference("shared", "amount", SCOPE)).toEqual({ + form: "bare", + ok: true, + value: "from params", + }); + }); + + test("falls back to an argument", () => { + expect(resolveReference("note", "amount", SCOPE)).toEqual({ + form: "bare", + ok: true, + value: "from args", + }); + }); + + test("refuses a bare name that is neither", () => { + expect(resolveReference("nowhere", "amount", SCOPE).ok).toBe(false); + }); + }); + + describe("what it refuses", () => { + test("a namespace the scope does not carry", () => { + const result = resolveReference("instance.OWNER", "compileParam", { params: {} }); + + expect(result.ok ? "" : result.reason).toContain("instance"); + }); + + test("a name the namespace does not carry, saying which name", () => { + const result = resolveReference("params.missing", "compileParam", SCOPE); + + expect(result.ok ? "" : result.reason).toContain("missing"); + }); + + test("an attribute of an input the wallet has not resolved", () => { + expect(resolveReference("other_in.amount_sat", "amount", SCOPE).ok).toBe(false); + }); + + test("text that is not a reference at all", () => { + const result = resolveReference("params.a + 1", "amount", SCOPE); + + expect(result.ok ? "" : result.reason).toContain("params.a + 1"); + }); + + test("the fee before the wallet has established one", () => { + expect(resolveReference("fee", "amount", { params: {} }).ok).toBe(false); + }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/references.ts b/apps/extension/src/core/chains/liquid/domain/manifest/references.ts new file mode 100644 index 0000000..cb445e7 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/references.ts @@ -0,0 +1,396 @@ +import { asArray, asRecord } from "./json"; +import type { NormalisationNote, NormalisedAction, NormalisedManifest } from "./normalise"; +import { namedUtxoTypes } from "./sites"; + +/** + * The shapes a reference can take. + * + * These are not variations on a syntax; they are six different lookups that happen to be + * written as strings. `fee` is the wallet's own figure, `instance` is this deployment's + * field values, `params` and `args` are the request's, `bare` is whichever of the last two + * has the name, and `input-attribute` is something the wallet established about a + * transaction input by reading the chain. + */ +export type ReferenceForm = "args" | "bare" | "fee" | "input-attribute" | "instance" | "params"; + +export type ParsedReference = { + /** The attribute being read, for the input-attribute form. */ + attribute?: string; + /** Whether the document used a spelling the format has deprecated. */ + deprecated?: boolean; + form: ReferenceForm; + /** The name being looked up; empty for `fee`, which names nothing. */ + name: string; +}; + +/** + * What a reference can be resolved against. + * + * Everything is optional except the request's parameters, because a reference resolves + * against whatever exists at the moment it is asked — an amount referencing the fee cannot + * be resolved before the fee is established, and saying so is more useful than resolving + * it to zero. + */ +export type ReferenceScope = { + args?: Record<string, unknown>; + /** The wallet's own fee figure, once it has one. */ + fee?: bigint; + /** What the wallet established about each named input, keyed by the manifest's id. */ + inputs?: Record<string, Record<string, unknown>>; + /** This deployment's field values. */ + instance?: Record<string, unknown>; + params: Record<string, unknown>; +}; + +/** + * The value and how it was found — and deliberately nothing about how it was spelled. + * + * Two documents writing one lookup in two accepted spellings must be indistinguishable to + * everything downstream, so a deprecation marker cannot ride on the result. That a + * deprecated spelling was used is recorded on the notes channel instead, beside the key + * renames the normalisation layer records, where it informs a reader without changing a + * value. + */ +export type ReferenceResolution = + | { form: ReferenceForm; ok: true; value: unknown } + | { ok: false; reason: string }; + +/** + * A position in a manifest where a reference may appear, and the forms it accepts there. + * + * This is the cornerstone: a reference means what its position says it may mean, not what + * its text looks like. The same string is a legitimate compile parameter in one place and + * nonsense in another, and the difference is not detectable from the string. + * + * The clearest case is `fee`. A covenant's address is derived from its compile parameters, + * and the fee is derived from the transaction that pays to that address — so a compile + * parameter referencing the fee is circular. Listing the accepted forms per site makes that + * unrepresentable rather than a mistake to be caught downstream. + */ +export type ReferenceSiteKind = + | "amount" + | "compileParam" + | "destination" + | "expression" + | "witnessKey"; + +const SITES: Record<ReferenceSiteKind, { accepts: ReferenceForm[]; describes: string }> = { + /** An output's amount, or an input's minimum. */ + amount: { + accepts: ["fee", "instance", "params", "args", "input-attribute", "bare"], + describes: "an amount", + }, + /** A value compiled into a contract, which therefore decides its address. */ + compileParam: { + accepts: ["instance", "params", "args", "bare"], + describes: "a compile parameter", + }, + /** Where an output pays, when it names a parameter rather than a keyword. */ + destination: { accepts: ["params"], describes: "a destination" }, + /** A validation's expression. Evaluating the expression is a later slice; this resolves one term of it. */ + expression: { + accepts: ["fee", "instance", "params", "args", "input-attribute", "bare"], + describes: "an expression", + }, + /** The key a witness is produced from. */ + witnessKey: { + accepts: ["instance", "params", "args", "bare"], + describes: "a witness key", + }, +}; + +/** The namespaces a prefixed reference can name, and what each canonically resolves as. */ +const NAMESPACES: Record<string, { deprecated: boolean; form: ReferenceForm }> = { + args: { deprecated: false, form: "args" }, + // The format is mid-rename from compile_params. to instance.; both are live in the + // corpus, and one manifest generation writes each. They are the same lookup. + compile_params: { deprecated: true, form: "instance" }, + instance: { deprecated: false, form: "instance" }, + params: { deprecated: false, form: "params" }, +}; + +const NAME = "[A-Za-z_][A-Za-z0-9_]*"; +const REFERENCE = new RegExp(`^\\$?(?<head>${NAME})(?:\\.(?<tail>${NAME}))?$`); + +/** + * Reads one reference, or reports that the text is not one. + * + * Deliberately not an expression parser: `params.a + 1` is an expression whose terms happen + * to include a reference, and evaluating it is the phased-evaluation slice's subject. This + * returns nothing for it rather than resolving the first term and losing the rest. + */ +export function parseReference(text: string): ParsedReference | undefined { + const match = REFERENCE.exec(text.trim()); + const head = match?.groups?.head; + + if (!head) { + return undefined; + } + + const tail = match?.groups?.tail; + + if (tail === undefined) { + return head === "fee" ? { form: "fee", name: "" } : { form: "bare", name: head }; + } + + const namespace = NAMESPACES[head]; + + if (namespace) { + return { + ...(namespace.deprecated ? { deprecated: true } : {}), + form: namespace.form, + name: tail, + }; + } + + // Anything else with one dot names an input and an attribute of it — `amount_sat`, + // `asset`, or something the wallet derived such as a reissuance token. + return { attribute: tail, form: "input-attribute", name: head }; +} + +/** + * Resolves one reference at one site. + * + * A refusal names both the text and what was wrong with it, because the reader of that + * message is a person deciding whether to trust a site, not the author of the manifest. + * + * `notes` collects the deprecated spellings encountered. It is optional because most + * callers only want the value; a caller building something a person will read passes one + * so the document's generation can be reported. + */ +export function resolveReference( + text: string, + site: ReferenceSiteKind, + scope: ReferenceScope, + notes?: NormalisationNote[], +): ReferenceResolution { + const reference = parseReference(text); + const accepted = SITES[site]; + + if (!reference) { + return { ok: false, reason: `"${text}" is not a reference.` }; + } + + if (!accepted.accepts.includes(reference.form)) { + return { + ok: false, + reason: `"${text}" cannot be used as ${accepted.describes}.`, + }; + } + + if (reference.deprecated) { + notes?.push({ at: `${accepted.describes}`, canonical: "instance.", found: "compile_params." }); + } + + const found = lookUp(reference, scope); + + return found.ok ? { form: reference.form, ok: true, value: found.value } : found; +} + +function lookUp( + reference: ParsedReference, + scope: ReferenceScope, +): { ok: true; value: unknown } | { ok: false; reason: string } { + switch (reference.form) { + case "args": { + return read(scope.args, reference.name, "args"); + } + + // Tried as a parameter first and then as an argument, which is the order the + // reference implementation uses. An unqualified word is ambiguous by design: the + // format offers no way to say which of the two was meant. + case "bare": { + if (scope.params && reference.name in scope.params) { + return { ok: true, value: scope.params[reference.name] }; + } + + if (scope.args && reference.name in scope.args) { + return { ok: true, value: scope.args[reference.name] }; + } + + return { + ok: false, + reason: `"${reference.name}" is neither a parameter nor an argument of this action.`, + }; + } + + case "fee": { + return scope.fee === undefined + ? { ok: false, reason: "The fee is referenced before the wallet has established one." } + : { ok: true, value: scope.fee }; + } + + case "input-attribute": { + const input = scope.inputs?.[reference.name]; + + if (!input) { + return { + ok: false, + reason: `"${reference.name}" is not an input this action resolved.`, + }; + } + + return read(input, reference.attribute ?? "", `input ${reference.name}`); + } + + case "instance": { + return read(scope.instance, reference.name, "instance"); + } + + case "params": { + return read(scope.params, reference.name, "params"); + } + } +} + +function read( + source: Record<string, unknown> | undefined, + name: string, + label: string, +): { ok: true; value: unknown } | { ok: false; reason: string } { + if (!source) { + return { ok: false, reason: `This request carries no ${label} to resolve "${name}" against.` }; + } + + if (!(name in source)) { + return { ok: false, reason: `${label} carries no "${name}".` }; + } + + return { ok: true, value: source[name] }; +} + +/** One reference the runtime found, and the position that says what it may mean. */ +export type ReferenceOccurrence = { + /** Where it is, in the document's own terms. */ + at: string; + site: ReferenceSiteKind; + text: string; +}; + +/** Destination words that are keywords rather than references. */ +const DESTINATION_KEYWORDS = new Set(["change", "wallet"]); + +/** + * Every reference an action reaches, with the site each one sits at. + * + * This is the enumeration the rest of the runtime asks instead of searching a document for + * reference-shaped text. The difference is not tidiness: a search finds `params.pubkey` + * inside a description and treats it as a reference, and misses one at a position it did + * not think to look. Positions are declared here once. + * + * Expression sites are tokenised rather than parsed — the occurrences it reports are the + * terms an expression mentions, which is what a caller asking "does this action read the + * instance file" needs. Evaluating the expression, and ordering the evaluation, belong to + * the phased-evaluation slice. + */ +export function actionReferences( + manifest: NormalisedManifest, + action: NormalisedAction, +): ReferenceOccurrence[] { + const found: ReferenceOccurrence[] = []; + const where = `action ${action.name}`; + + const add = (site: ReferenceSiteKind, at: string, value: unknown): void => { + if (typeof value !== "string") { + return; + } + + if (site === "expression") { + for (const token of expressionTerms(value)) { + found.push({ at, site, text: token }); + } + + return; + } + + found.push({ at, site, text: value }); + }; + + const addWiring = (at: string, wiring: unknown): void => { + for (const [name, value] of Object.entries(asRecord(wiring) ?? {})) { + add("compileParam", `${at} / ${name}`, value); + } + }; + + for (const declared of asArray(action.node.inputs)) { + const input = asRecord(declared); + const at = `${where} / input ${identifierOf(input)}`; + + addWiring(at, asRecord(input?.utxo_source)?.compile_params); + add("amount", `${at} / amount_sat`, input?.amount_sat); + add("amount", `${at} / amount_sat`, asRecord(input?.amount_sat)?.min_amount); + + for (const [name, witness] of Object.entries(asRecord(input?.witnesses) ?? {})) { + add("witnessKey", `${at} / witness ${name}`, asRecord(asRecord(witness)?.source)?.key); + } + } + + for (const declared of asArray(action.node.outputs)) { + const output = asRecord(declared); + const at = `${where} / output ${identifierOf(output)}`; + const destination = output?.destination; + + addWiring(at, asRecord(destination)?.compile_params); + add("amount", `${at} / amount_sat`, output?.amount_sat); + + if (typeof destination === "string" && !DESTINATION_KEYWORDS.has(destination)) { + add("destination", `${at} / destination`, destination); + } + } + + for (const declared of asArray(action.node.validations)) { + const validation = asRecord(declared); + const at = `${where} / validation ${identifierOf(validation)}`; + + add("expression", at, asRecord(validation?.rule)?.expr); + } + + // A covenant's parameters can also be wired on the utxo type itself rather than at the + // site that names it, so the types this action reaches are part of its reference surface. + for (const name of namedUtxoTypes(action)) { + const script = asRecord(asRecord(manifest.utxoTypes[name])?.script); + + addWiring(`utxo type ${name} / script`, script?.compile_params); + } + + return found; +} + +/** Whether the action reads this deployment's field values, under either spelling. */ +export function readsInstance(manifest: NormalisedManifest, action: NormalisedAction): boolean { + return instanceReferences(manifest, action).length > 0; +} + +/** + * Every place the action reads this deployment's field values. + * + * Returned rather than reduced to a flag because a free action reaching for them is a + * document that cannot be satisfied — a deployment's fields belong to a class, and an + * action declared outside one has no deployment to read. Naming the positions is what lets + * that be said rather than merely detected. + */ +export function instanceReferences( + manifest: NormalisedManifest, + action: NormalisedAction, +): ReferenceOccurrence[] { + return actionReferences(manifest, action).filter( + (occurrence) => parseReference(occurrence.text)?.form === "instance", + ); +} + +/** + * The reference-shaped terms of an expression, without evaluating it. + * + * Splitting on everything a name cannot contain is enough to find the terms and is not + * enough to evaluate anything, which is the intent: this answers "what does this mention", + * and the phased-evaluation slice answers "what does this come to". + */ +function expressionTerms(text: string): string[] { + return text + .split(/[^A-Za-z0-9_.$]+/) + .filter((term) => term.length > 0 && parseReference(term) !== undefined); +} + +function identifierOf(node: Record<string, unknown> | undefined): string { + return typeof node?.id === "string" ? node.id : "(unnamed)"; +} From 62e3e0bdd13feaea1a486c7bb7772f632e3290a1 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 14:14:31 +0300 Subject: [PATCH 034/124] feat(liquid): say which parts of a manifest the wallet did not read A construct nobody implements is invisible in code and visible in a table, so the gap between what the format can say and what the wallet can honour becomes something to read rather than something to notice. Each entry says whether reading it wrong could change what gets signed. That flag is what makes an ignored construct reportable. attestation_version appears in three published manifests and no implementation reads it, including the reference one; ignoring it is right and ignoring it silently is not. A key in a position that decides what is spent gets the opposite default, and the refusal built on it is a later slice. --- .../liquid/domain/manifest/registry.test.ts | 158 +++++++++ .../chains/liquid/domain/manifest/registry.ts | 323 ++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/registry.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts new file mode 100644 index 0000000..64ec27b --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; + +import flatManifest from "./__fixtures__/p2pk.manifest.json"; +import { normaliseManifest } from "./normalise"; +import { type ConstructFinding, ignored, inspectConstructs, loadBearing } from "./registry"; + +// Expectations come from the cross-source construct inventory in the change bundle +// (artifacts/manifest-inventory.md) and from AC-02, not from what the registry happens to +// contain. A construct the format carries and this runtime does not act on is a finding; +// whether that finding refuses is a later slice's question. + +const flat = flatManifest as unknown as Record<string, unknown>; + +function inspect(raw: Record<string, unknown>): ConstructFinding[] { + return inspectConstructs(normaliseManifest(raw).manifest); +} + +function at(findings: ConstructFinding[], key: string): ConstructFinding | undefined { + return findings.find((finding) => finding.key === key); +} + +describe("inspectConstructs", () => { + describe("what it reports at all", () => { + test("says nothing about a construct the runtime acts on", () => { + expect(at(inspect(flat), "utxo_types")).toBeUndefined(); + expect(at(inspect(flat), "destination")).toBeUndefined(); + }); + + test("says nothing about a description, which is text for a person", () => { + expect(at(inspect(flat), "description")).toBeUndefined(); + }); + + test("names where it found something, in the document's own terms", () => { + const finding = at(inspect(flat), "validations"); + + expect(finding?.at).toBe("action Pay"); + }); + }); + + // AC-02, decorative half. `attestation_version` appears in three published manifests + // and is read by no implementation, including the reference one. Ignoring it is + // correct; ignoring it silently is what the criterion forbids. + describe("decorative constructs are ignored, and the fact recorded", () => { + test("reports attestation_version as ignored rather than dropping it", () => { + const finding = at(inspect(flat), "attestation_version"); + + expect(finding).toMatchObject({ at: "manifest", declared: true, loadBearing: false }); + }); + + test("ignores an unrecognised key inside a display block", () => { + const findings = inspect({ + actions: { Pay: { ui: { icon: "vault.svg", label: "Pay" } } }, + }); + + expect(at(findings, "icon")).toMatchObject({ declared: false, loadBearing: false }); + }); + + test("does not report a display key it knows", () => { + const findings = inspect({ actions: { Pay: { ui: { label: "Pay" } } } }); + + expect(at(findings, "label")).toBeUndefined(); + }); + + test("collects the ignored ones for reporting", () => { + expect(ignored(inspect(flat)).map((finding) => finding.key)).toContain("attestation_version"); + }); + }); + + // The refusal itself is ISSUE-021. What this slice owes it is the distinction. + describe("load-bearing constructs are separated out", () => { + test("a construct the format declares and this runtime does not implement", () => { + const finding = at(inspect(flat), "validations"); + + expect(finding).toMatchObject({ declared: true, loadBearing: true }); + }); + + test("an unrecognised key on an input, where being wrong changes what is spent", () => { + const findings = inspect({ + actions: { Pay: { inputs: [{ id: "a", teleport: true, utxo_source: "wallet" }] } }, + }); + + expect(at(findings, "teleport")).toMatchObject({ + at: "action Pay / input a", + declared: false, + loadBearing: true, + }); + }); + + test("an unrecognised key on an output", () => { + const findings = inspect({ + actions: { Pay: { outputs: [{ destination: "change", id: "b", rebate: 5 }] } }, + }); + + expect(at(findings, "rebate")?.at).toBe("action Pay / output b"); + }); + + test("collects the load-bearing ones for the slice that refuses on them", () => { + const findings = inspect({ + actions: { Pay: { on_validate: "fn main() {}" } }, + }); + + expect(loadBearing(findings).map((finding) => finding.key)).toContain("on_validate"); + }); + }); + + describe("the sites it reaches", () => { + test("a witness on an input", () => { + const findings = inspect(flat); + + expect(at(findings, "sig_type")?.at).toBe( + "action Receive / input p2pk_in / witness SIGNATURE", + ); + }); + + test("a script under a utxo type", () => { + const findings = inspect({ + utxo_types: { vault: { script: { extra_leaves: [], source: "./a.simf" } } }, + }); + + expect(at(findings, "extra_leaves")?.at).toBe("utxo type vault / script"); + }); + + test("a parameter definition", () => { + const findings = inspect({ + actions: { Pay: { params: { owner: { default: "0x00", type: "pubkey" } } } }, + }); + + expect(at(findings, "default")?.at).toBe("action Pay / param owner"); + }); + + test("a validation rule", () => { + const findings = inspect(flat); + + expect(at(findings, "rule")?.at).toBe("action Pay / validation amount_nonzero"); + }); + + test("a grouped method is reached the same way a flat action is", () => { + const findings = inspect({ + classes: { Vault: { methods: { Open: { on_validate: "fn main() {}" } } } }, + }); + + expect(at(findings, "on_validate")?.at).toBe("action Open"); + }); + }); + + describe("what it does not claim", () => { + test("an empty manifest produces nothing", () => { + expect(inspect({})).toEqual([]); + }); + + test("a legacy spelling is not reported, because normalisation already rewrote it", () => { + const findings = inspect({ actions: { Open: { deploy: true } }, compose_version: "0.1.0" }); + + expect(at(findings, "deploy")).toBeUndefined(); + expect(at(findings, "compose_version")).toBeUndefined(); + }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts b/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts new file mode 100644 index 0000000..488868a --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts @@ -0,0 +1,323 @@ +import { asArray, asRecord } from "./json"; +import type { NormalisedManifest } from "./normalise"; + +/** + * One construct the runtime met and did not act on. + * + * The two flags answer different questions. `declared` says whether the format is known to + * carry this construct at all — the difference between "this wallet does not implement + * validations yet" and "nobody has ever seen this field". `loadBearing` says whether being + * wrong about it can change what gets signed, which is what decides between ignoring it + * and refusing. + */ +export type ConstructFinding = { + /** Where it was found, in the document's own terms. */ + at: string; + /** Whether the format is known to carry this construct. */ + declared: boolean; + key: string; + /** Whether reading it wrong could change what gets signed. */ + loadBearing: boolean; +}; + +/** What was ignored, and can be reported as ignored. */ +export function ignored(findings: ConstructFinding[]): ConstructFinding[] { + return findings.filter((finding) => !finding.loadBearing); +} + +/** What a refusal has to be built on. */ +export function loadBearing(findings: ConstructFinding[]): ConstructFinding[] { + return findings.filter((finding) => finding.loadBearing); +} + +/** + * How the runtime treats one construct at one kind of site. + * + * `handled` is a claim about this codebase rather than about the format, and it is the + * reason the table is worth having: a construct nobody implements is invisible in code and + * visible here, so the gap between what the format can say and what the wallet can honour + * is a table to read rather than an absence to notice. + */ +type Construct = { + /** Whether the runtime acts on it today. An unhandled one becomes a finding. */ + handled: boolean; + /** Whether reading it wrong could change what gets signed. */ + loadBearing: boolean; +}; + +const READ: Construct = { handled: true, loadBearing: true }; +const SHOWN: Construct = { handled: true, loadBearing: false }; +const UNIMPLEMENTED: Construct = { handled: false, loadBearing: true }; +const UNREAD: Construct = { handled: false, loadBearing: false }; + +/** + * One kind of position in a manifest, and what it may contain. + * + * `unknownIsLoadBearing` is the site's own answer for a key nobody has listed. It is true + * almost everywhere, because an unlisted field in a position that describes what is spent + * or created is exactly the case cornerstone 4 exists for. It is false only where the + * whole position is text for a person — and that is what makes an unrecognised *decorative* + * construct a thing that can exist rather than a category with no members. + */ +type ConstructSite = { + constructs: Record<string, Construct>; + unknownIsLoadBearing: boolean; +}; + +/** + * The construct table, keyed by site kind and then by the key's own name. + * + * Adding a construct to the runtime is an edit here plus the code that reads it. That is + * the whole point of the table: the alternative is a condition somewhere in a function + * that already does something else, which is how a manifest interpreter turns into a + * switch statement nobody can audit. + * + * Sourced from the cross-source inventory in the change bundle, which reconciles the + * cookbook, the published specification, the ELIP draft, the seven example manifests and + * the reference implementation. + */ +const SITES = { + action: { + constructs: { + args: UNIMPLEMENTED, + create_instance: UNIMPLEMENTED, + description: SHOWN, + inputs: READ, + is_constructor: UNIMPLEMENTED, + on_input_resolved: UNIMPLEMENTED, + on_post_broadcast: UNIMPLEMENTED, + on_pre_broadcast: UNIMPLEMENTED, + // A full SimplicityHL program, not a formula: honouring it means executing a + // contract at build time. Out of scope for this change and named rather than absent. + on_validate: UNIMPLEMENTED, + outputs: READ, + params: READ, + ui: SHOWN, + validations: UNIMPLEMENTED, + witnesses: UNIMPLEMENTED, + }, + unknownIsLoadBearing: true, + }, + input: { + constructs: { + amount_sat: UNIMPLEMENTED, + asset: UNIMPLEMENTED, + description: SHOWN, + from_address: UNIMPLEMENTED, + id: READ, + issuance: UNIMPLEMENTED, + on_resolved: UNIMPLEMENTED, + optional: UNIMPLEMENTED, + // Covenants depend on input and output ordering and no implementation enforces + // this, so a manifest asking for index 0 and getting 1 builds a transaction the + // covenant rejects on chain. + required_index: UNIMPLEMENTED, + sequence: UNIMPLEMENTED, + ui: SHOWN, + utxo_source: READ, + witnesses: UNIMPLEMENTED, + }, + unknownIsLoadBearing: true, + }, + manifest: { + constructs: { + $comment: SHOWN, + // Reserved for a signature slot that does not exist, and read by no + // implementation including the reference one. + attestation_version: UNREAD, + actions: READ, + chain: UNIMPLEMENTED, + classes: READ, + compile_debug_symbols: UNIMPLEMENTED, + confidential_outputs: UNIMPLEMENTED, + description: SHOWN, + errors: SHOWN, + lifecycle: SHOWN, + manifest_version: READ, + params: READ, + protocol: SHOWN, + simplicity_hl_version: UNIMPLEMENTED, + source: UNIMPLEMENTED, + utxo_types: READ, + }, + unknownIsLoadBearing: true, + }, + output: { + constructs: { + amount_sat: READ, + asset: UNIMPLEMENTED, + condition: UNIMPLEMENTED, + confidential: UNIMPLEMENTED, + data: UNIMPLEMENTED, + description: SHOWN, + destination: READ, + id: READ, + optional: UNIMPLEMENTED, + required_index: UNIMPLEMENTED, + ui: SHOWN, + }, + unknownIsLoadBearing: true, + }, + param: { + constructs: { + compute: UNIMPLEMENTED, + default: UNIMPLEMENTED, + derived: UNIMPLEMENTED, + description: SHOWN, + // The reference implementation's own comment calls it informational only for + // display, so it does not decide a value and cannot change what is signed. + formula: UNREAD, + source: UNIMPLEMENTED, + type: READ, + }, + unknownIsLoadBearing: true, + }, + script: { + constructs: { + compile_params: READ, + extra_leaves: UNIMPLEMENTED, + source: READ, + type: UNIMPLEMENTED, + }, + unknownIsLoadBearing: true, + }, + /** Display metadata the protocol author wrote. Nothing here decides a value. */ + ui: { + constructs: { + action: SHOWN, + group: SHOWN, + hide: SHOWN, + label: SHOWN, + role: SHOWN, + }, + unknownIsLoadBearing: false, + }, + utxoType: { + constructs: { + asset: UNIMPLEMENTED, + confidential: UNIMPLEMENTED, + description: SHOWN, + script: READ, + state_vars: UNIMPLEMENTED, + }, + unknownIsLoadBearing: true, + }, + validation: { + constructs: { + description: SHOWN, + error: SHOWN, + error_code: SHOWN, + id: SHOWN, + rule: UNIMPLEMENTED, + }, + unknownIsLoadBearing: true, + }, + witness: { + constructs: { + description: SHOWN, + sig_type: UNIMPLEMENTED, + simplicity_type: UNIMPLEMENTED, + source: UNIMPLEMENTED, + type: UNIMPLEMENTED, + value: UNIMPLEMENTED, + }, + unknownIsLoadBearing: true, + }, +} satisfies Record<string, ConstructSite>; + +type SiteKind = keyof typeof SITES; + +/** + * Walks a normalised manifest and reports every construct the runtime does not act on. + * + * It runs over the whole document rather than only the action being performed, because + * the format's own conformance rule is stated about the manifest: a tool that does not + * implement an extension must reject a manifest using its fields. Each finding names where + * it is, so a later slice can still choose to refuse on reach rather than on presence + * without this having thrown the information away. + */ +export function inspectConstructs(manifest: NormalisedManifest): ConstructFinding[] { + const findings: ConstructFinding[] = []; + + inspectSite(manifest.node, "manifest", "manifest", findings); + + for (const action of manifest.actions) { + const where = `action ${action.name}`; + + inspectSite(action.node, "action", where, findings); + inspectSite(asRecord(action.node.ui), "ui", where, findings); + + for (const [name, declared] of Object.entries(asRecord(action.node.params) ?? {})) { + inspectSite(asRecord(declared), "param", `${where} / param ${name}`, findings); + } + + inspectEntries(asArray(action.node.inputs), "input", where, findings); + inspectEntries(asArray(action.node.outputs), "output", where, findings); + + for (const declared of asArray(action.node.validations)) { + const rule = asRecord(declared); + const id = typeof rule?.id === "string" ? rule.id : "(unnamed)"; + + inspectSite(rule, "validation", `${where} / validation ${id}`, findings); + } + } + + for (const [name, declared] of Object.entries(manifest.utxoTypes)) { + const where = `utxo type ${name}`; + const utxoType = asRecord(declared); + + inspectSite(utxoType, "utxoType", where, findings); + inspectSite(asRecord(utxoType?.script), "script", `${where} / script`, findings); + } + + return findings; +} + +/** Inputs and outputs both carry an id, a display block and per-entry witnesses. */ +function inspectEntries( + entries: unknown[], + kind: "input" | "output", + where: string, + findings: ConstructFinding[], +): void { + for (const declared of entries) { + const entry = asRecord(declared); + const id = typeof entry?.id === "string" ? entry.id : "(unnamed)"; + const at = `${where} / ${kind} ${id}`; + + inspectSite(entry, kind, at, findings); + inspectSite(asRecord(entry?.ui), "ui", at, findings); + + for (const [name, witness] of Object.entries(asRecord(entry?.witnesses) ?? {})) { + inspectSite(asRecord(witness), "witness", `${at} / witness ${name}`, findings); + } + } +} + +function inspectSite( + node: Record<string, unknown> | undefined, + kind: SiteKind, + at: string, + findings: ConstructFinding[], +): void { + if (!node) { + return; + } + + const site: ConstructSite = SITES[kind]; + + for (const key of Object.keys(node)) { + const construct = site.constructs[key]; + + if (construct?.handled) { + continue; + } + + findings.push({ + at, + declared: construct !== undefined, + key, + loadBearing: construct ? construct.loadBearing : site.unknownIsLoadBearing, + }); + } +} From 2cffed886b159aae437c21e363fecec4d98babba Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 14:14:34 +0300 Subject: [PATCH 035/124] refactor(liquid): read the manifest through the runtime core, not eleven times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tracer bullet was allowed the shortest honest path and took it: the manifest was read at eleven points across five files, an action was found by name twice, its parameters walked twice for two halves of one fact, and four regexes in three files each resolved their own idea of a reference. All of it now goes through one normalised document, one construct enumeration and one resolver. Two corrections fall out. A parameter carrying a display formula is still one the request must fill — the reference implementation never evaluates the field and says so in a comment — where before it was treated as derived and a short request was accepted, to fail later and further from the cause. And an action declared outside a class that reaches for a deployment's field values is now refused as a fault in the document, because no instance file could satisfy it. --- .../domain/manifest/compileParams.test.ts | 37 ++-- .../liquid/domain/manifest/compileParams.ts | 54 +++--- .../liquid/domain/manifest/covenant.test.ts | 47 ++--- .../chains/liquid/domain/manifest/covenant.ts | 51 ++---- .../liquid/domain/manifest/plan.test.ts | 54 +++--- .../chains/liquid/domain/manifest/plan.ts | 91 ++++----- .../domain/manifest/requirements.test.ts | 128 ++++++++++++- .../liquid/domain/manifest/requirements.ts | 172 +++++------------- .../liquid/domain/manifest/review.test.ts | 71 ++++++++ .../chains/liquid/domain/manifest/review.ts | 142 +++++---------- 10 files changed, 437 insertions(+), 410 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts index 888a3ee..2120a21 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { resolveCompileParams } from "./compileParams"; -import type { ParsedLiquidProcessCtParams } from "./types"; +import type { ReferenceScope } from "./references"; // The wiring and the declared types below are the published p2pk manifest's own: // `Pay` writes a destination with compile_params {"PUB_KEY": "params.pubkey"}, and the @@ -9,22 +9,19 @@ import type { ParsedLiquidProcessCtParams } from "./types"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; -function request(params: Record<string, unknown>): ParsedLiquidProcessCtParams { - return { - action: "Pay", - broadcast: false, - contractSources: {}, - manifest: {}, - params, - }; +function scope( + params: Record<string, unknown>, + instance?: Record<string, unknown>, +): ReferenceScope { + return { instance, params }; } describe("resolveCompileParams", () => { test("resolves a params reference into the compiler's argument shape", () => { const result = resolveCompileParams( - request({ pubkey: PUBKEY }), { PUB_KEY: "params.pubkey" }, { pubkey: "pubkey" }, + scope({ pubkey: PUBKEY }), ); expect(result).toEqual({ @@ -35,9 +32,9 @@ describe("resolveCompileParams", () => { test("accepts the $-prefixed spelling the corpus also uses", () => { const result = resolveCompileParams( - request({ pubkey: PUBKEY }), { PUB_KEY: "$params.pubkey" }, { pubkey: "pubkey" }, + scope({ pubkey: PUBKEY }), ); expect(result.ok).toBe(true); @@ -45,9 +42,9 @@ describe("resolveCompileParams", () => { test("leaves an already-prefixed value alone", () => { const result = resolveCompileParams( - request({ pubkey: `0x${PUBKEY}` }), { PUB_KEY: "params.pubkey" }, { pubkey: "pubkey" }, + scope({ pubkey: `0x${PUBKEY}` }), ); expect(result).toMatchObject({ @@ -57,22 +54,22 @@ describe("resolveCompileParams", () => { test("refuses when the referenced parameter was not supplied", () => { const result = resolveCompileParams( - request({}), { PUB_KEY: "params.pubkey" }, { pubkey: "pubkey" }, + scope({}), ); expect(result.ok).toBe(false); }); - // A reference this runtime cannot resolve must refuse rather than resolve to something - // plausible: the value participates in the covenant address, so a wrong one produces a - // well-formed address for the wrong contract. - test("refuses an instance reference rather than guessing", () => { + // An instance field now resolves to a value, and still cannot be encoded: nothing + // declares its type, and the value participates in the covenant address, so encoding it + // at a guessed width produces a well-formed address for the wrong contract. + test("refuses an instance reference, because nothing declares its type", () => { const result = resolveCompileParams( - request({ pubkey: PUBKEY }), { PUB_KEY: "instance.OWNER" }, { pubkey: "pubkey" }, + scope({ pubkey: PUBKEY }, { OWNER: "0x01" }), ); expect(result).toMatchObject({ ok: false }); @@ -80,9 +77,9 @@ describe("resolveCompileParams", () => { test("refuses a declared type it does not encode", () => { const result = resolveCompileParams( - request({ owner: "someone" }), { PUB_KEY: "params.owner" }, { owner: "address" }, + scope({ owner: "someone" }), ); expect(result).toMatchObject({ ok: false }); @@ -90,9 +87,9 @@ describe("resolveCompileParams", () => { test("refuses a parameter with no declared type", () => { const result = resolveCompileParams( - request({ pubkey: PUBKEY }), { PUB_KEY: "params.pubkey" }, {}, + scope({ pubkey: PUBKEY }), ); expect(result).toMatchObject({ ok: false }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts index 8cc92b8..1a57638 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts @@ -1,4 +1,5 @@ -import type { ParsedLiquidProcessCtParams } from "./types"; +import type { NormalisationNote } from "./normalise"; +import { type ReferenceScope, resolveReference } from "./references"; /** * A contract's compile-time parameters, in SimplicityHL's own argument JSON shape. @@ -26,20 +27,20 @@ const PARAM_TYPES: Record<string, string> = { /** * Resolves the compile-time parameters a contract is built with, from the manifest's - * wiring and the parameters the request filled. + * wiring and what the request and the deployment supply. * * The wiring lives in `compile_params`, a map of the contract's parameter name to a * reference — `{"PUB_KEY": "params.pubkey"}`. Note the collision the format carries: * `compile_params` is both this wiring map and a deprecated namespace prefix for - * references. This reads the wiring; the namespace is a later slice's problem. - * - * Scope: resolves `params.` references only. Instance references and formulas belong to - * the slices that own them, and are refused here rather than silently mishandled. + * references. This map is read as wiring; a reference inside it is resolved at the + * compile-parameter site, which is what decides that `instance.`, `params.`, `args.` and a + * bare name are meaningful here and the fee is not. */ export function resolveCompileParams( - request: ParsedLiquidProcessCtParams, wiring: Record<string, unknown>, declaredTypes: Record<string, string>, + scope: ReferenceScope, + notes?: NormalisationNote[], ): ResolveCompileParamsResult { const resolved: ContractArguments = {}; @@ -48,50 +49,51 @@ export function resolveCompileParams( return { ok: false, reason: `Compile parameter ${name} is not a reference.` }; } - const paramName = referencedParam(reference); + const found = resolveReference(reference, "compileParam", scope, notes); - if (!paramName) { - return { - ok: false, - reason: `Compile parameter ${name} references ${reference}, which this runtime does not resolve yet.`, - }; + if (!found.ok) { + return { ok: false, reason: `Compile parameter ${name}: ${found.reason}` }; } - const value = request.params[paramName]; - - if (typeof value !== "string") { + if (typeof found.value !== "string") { return { ok: false, - reason: `Compile parameter ${name} needs parameter ${paramName}, which the request did not supply as a value.`, + reason: `Compile parameter ${name} resolves to ${reference}, which is not a value this runtime can encode yet.`, }; } - const declaredType = declaredTypes[paramName]; + // A compile parameter's type comes from the parameter the manifest declares, so a + // reference to something with no declared type has nothing to encode against. + const declaredType = declaredTypeOf(reference, declaredTypes); const compilerType = declaredType ? PARAM_TYPES[declaredType] : undefined; if (!compilerType) { return { ok: false, - reason: `Parameter ${paramName} is declared as ${declaredType ?? "an unstated type"}, which this runtime does not encode yet.`, + reason: `${reference} is declared as ${declaredType ?? "an unstated type"}, which this runtime does not encode yet.`, }; } - resolved[name] = { type: compilerType, value: withHexPrefix(value) }; + resolved[name] = { type: compilerType, value: withHexPrefix(found.value) }; } return { arguments: resolved, ok: true }; } /** - * The action parameter a reference points at, or undefined when it points elsewhere. + * The declared type of whatever a reference points at. * - * Accepts the `$`-prefixed spelling alongside the bare one: the corpus carries both, and - * `lending` uses one where `lending_v2` uses the other. + * Only the action's own parameters carry declared types today. An instance field or an + * argument has none, which is why a reference to one is refused here rather than encoded + * on a guess — encoding a value at the wrong width changes the address silently. */ -function referencedParam(reference: string): string | undefined { - const match = /^\$?params\.(?<name>[A-Za-z0-9_]+)$/.exec(reference); +function declaredTypeOf( + reference: string, + declaredTypes: Record<string, string>, +): string | undefined { + const name = /^\$?(?:params\.)?(?<name>[A-Za-z_][A-Za-z0-9_]*)$/.exec(reference)?.groups?.name; - return match?.groups?.name; + return name === undefined ? undefined : declaredTypes[name]; } function withHexPrefix(value: string): string { diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts index 384b79a..6cd647a 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; import { covenantMatchesChain, deriveCovenantAddress } from "./covenant"; -import type { ParsedLiquidProcessCtParams } from "./types"; +import { normaliseManifest } from "./normalise"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const SOURCE_PATH = "./p2pk.simf"; @@ -13,15 +13,17 @@ const SOURCE = const WIRING = { PUB_KEY: "params.pubkey" }; const DECLARED_TYPES = { pubkey: "pubkey" }; -function request( - overrides: Partial<ParsedLiquidProcessCtParams> = {}, -): ParsedLiquidProcessCtParams { +const MANIFEST = normaliseManifest(p2pkManifest as unknown as Record<string, unknown>).manifest; + +/** What a covenant derivation needs beyond the manifest, with the published wiring. */ +function request(overrides: Record<string, unknown> = {}) { return { - action: "Receive", - broadcast: false, contractSources: { [SOURCE_PATH]: SOURCE }, - manifest: p2pkManifest as unknown as Record<string, unknown>, - params: { pubkey: PUBKEY }, + declaredTypes: DECLARED_TYPES, + network: "liquid", + scope: { params: { pubkey: PUBKEY } }, + utxoType: "p2pk_output", + wiring: WIRING, ...overrides, }; } @@ -44,13 +46,7 @@ describe("deriveCovenantAddress", () => { test("compiles the source the request supplied, with the parameters the manifest wires", async () => { const { calls, compile } = compiler(); - const result = await deriveCovenantAddress(request(), { - compile, - declaredTypes: DECLARED_TYPES, - network: "liquid", - utxoType: "p2pk_output", - wiring: WIRING, - }); + const result = await deriveCovenantAddress(MANIFEST, { ...request(), compile }); expect(result).toMatchObject({ derivation: { utxoType: "p2pk_output" }, ok: true }); expect(calls).toHaveLength(1); @@ -61,38 +57,29 @@ describe("deriveCovenantAddress", () => { }); test("refuses a utxo type the manifest does not declare", async () => { - const result = await deriveCovenantAddress(request(), { + const result = await deriveCovenantAddress(MANIFEST, { + ...request({ utxoType: "vault" }), compile: compiler().compile, - declaredTypes: DECLARED_TYPES, - network: "liquid", - utxoType: "vault", - wiring: WIRING, }); expect(result).toMatchObject({ ok: false }); }); test("refuses when the contract source was not supplied", async () => { - const result = await deriveCovenantAddress(request({ contractSources: {} }), { + const result = await deriveCovenantAddress(MANIFEST, { + ...request({ contractSources: {} }), compile: compiler().compile, - declaredTypes: DECLARED_TYPES, - network: "liquid", - utxoType: "p2pk_output", - wiring: WIRING, }); expect(result).toMatchObject({ ok: false }); }); test("refuses when the source does not compile, rather than throwing", async () => { - const result = await deriveCovenantAddress(request(), { + const result = await deriveCovenantAddress(MANIFEST, { + ...request(), compile: () => { throw new Error("parse error"); }, - declaredTypes: DECLARED_TYPES, - network: "liquid", - utxoType: "p2pk_output", - wiring: WIRING, }); expect(result).toMatchObject({ ok: false }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts index 33890c9..4a4906a 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts @@ -1,5 +1,7 @@ import { resolveCompileParams } from "./compileParams"; -import type { ParsedLiquidProcessCtParams } from "./types"; +import { asRecord } from "./json"; +import type { NormalisationNote, NormalisedManifest } from "./normalise"; +import type { ReferenceScope } from "./references"; /** * Compiles a covenant and reports the address it derives. @@ -43,34 +45,44 @@ export type DeriveCovenantResult = * against. */ export async function deriveCovenantAddress( - request: ParsedLiquidProcessCtParams, + manifest: NormalisedManifest, input: { compile: CompileCovenant; + contractSources: Record<string, string>; declaredTypes: Record<string, string>; network: string; + notes?: NormalisationNote[]; + scope: ReferenceScope; utxoType: string; wiring: Record<string, unknown>; }, ): Promise<DeriveCovenantResult> { - const declared = utxoTypeDeclaration(request.manifest, input.utxoType); + const declared = asRecord(manifest.utxoTypes[input.utxoType]); if (!declared) { return { ok: false, reason: `The manifest declares no utxo type named "${input.utxoType}".` }; } - const sourcePath = declared.sourcePath; + const sourcePath = asRecord(declared.script)?.source; - if (!sourcePath) { + if (typeof sourcePath !== "string") { return { ok: false, reason: `Utxo type "${input.utxoType}" names no contract source.` }; } - const source = request.contractSources[sourcePath]; + const source = input.contractSources[sourcePath]; if (source === undefined) { return { ok: false, reason: `The source of ${sourcePath} was not supplied.` }; } - const params = resolveCompileParams(request, input.wiring, input.declaredTypes); + // The wiring at the site the covenant is named from, layered over the wiring the utxo + // type declares for itself — the site is more specific, so it wins. + const wiring = { + ...asRecord(asRecord(declared.script)?.compile_params), + ...input.wiring, + }; + + const params = resolveCompileParams(wiring, input.declaredTypes, input.scope, input.notes); if (!params.ok) { return params; @@ -123,28 +135,3 @@ export function covenantMatchesChain( `but the funds are at ${onChainAddress}. This is not the contract the site described.`, }; } - -function utxoTypeDeclaration( - manifest: Record<string, unknown>, - name: string, -): { sourcePath: string | undefined } | undefined { - const utxoTypes = manifest.utxo_types; - - if (typeof utxoTypes !== "object" || utxoTypes === null) { - return undefined; - } - - const declared = (utxoTypes as Record<string, unknown>)[name]; - - if (typeof declared !== "object" || declared === null) { - return undefined; - } - - const script = (declared as Record<string, unknown>).script; - const source = - typeof script === "object" && script !== null - ? (script as Record<string, unknown>).source - : undefined; - - return { sourcePath: typeof source === "string" ? source : undefined }; -} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts index e8b34d2..19f1c08 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts @@ -1,28 +1,28 @@ import { describe, expect, test } from "bun:test"; import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import { findAction, type NormalisedAction, normaliseManifest } from "./normalise"; import { planAction } from "./plan"; -import type { ParsedLiquidProcessCtParams } from "./types"; +import type { ReferenceScope } from "./references"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; -const MANIFEST = p2pkManifest as unknown as Record<string, unknown>; -const PAY = (MANIFEST.actions as Record<string, Record<string, unknown>>).Pay; - -function request(params: Record<string, unknown>): ParsedLiquidProcessCtParams { - return { - action: "Pay", - broadcast: false, - contractSources: {}, - manifest: MANIFEST, - params, - }; +const MANIFEST = normaliseManifest(p2pkManifest as unknown as Record<string, unknown>).manifest; +const PAY = findAction(MANIFEST, "Pay") as NormalisedAction; + +function request(params: Record<string, unknown>): ReferenceScope { + return { params }; +} + +/** An action written inline, for a shape the published manifest does not carry. */ +function action(node: Record<string, unknown>): NormalisedAction { + return { isConstructor: false, name: "Inline", node }; } describe("planAction", () => { // Pay declares two outputs: the covenant, whose amount is params.amount_sat, and an // optional change output. test("resolves the covenant amount from the request's parameters", () => { - const result = planAction(request({ amount_sat: 50_000, pubkey: PUBKEY }), PAY); + const result = planAction(PAY, request({ amount_sat: 50_000, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: true }); @@ -37,7 +37,7 @@ describe("planAction", () => { }); test("leaves change without an amount, because it is whatever survives the fee", () => { - const result = planAction(request({ amount_sat: 50_000, pubkey: PUBKEY }), PAY); + const result = planAction(PAY, request({ amount_sat: 50_000, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: true }); @@ -52,7 +52,7 @@ describe("planAction", () => { // Amounts are base units and must survive past 2^53, which a number cannot. test("keeps an amount beyond a double's range exact", () => { const huge = "9007199254740993"; - const result = planAction(request({ amount_sat: huge, pubkey: PUBKEY }), PAY); + const result = planAction(PAY, request({ amount_sat: huge, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: true }); @@ -63,47 +63,51 @@ describe("planAction", () => { test("refuses an amount it cannot evaluate rather than assuming one", () => { const result = planAction( - request({ amount_sat: "will_in.amount_sat - fee", pubkey: PUBKEY }), PAY, + request({ amount_sat: "will_in.amount_sat - fee", pubkey: PUBKEY }), ); expect(result).toMatchObject({ ok: false }); }); test("refuses when the referenced parameter was not supplied", () => { - const result = planAction(request({ pubkey: PUBKEY }), PAY); + const result = planAction(PAY, request({ pubkey: PUBKEY })); expect(result).toMatchObject({ ok: false }); }); test("refuses an output that would pay nothing", () => { - const result = planAction(request({ amount_sat: 0, pubkey: PUBKEY }), PAY); + const result = planAction(PAY, request({ amount_sat: 0, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: false }); }); test("refuses a destination it does not resolve", () => { - const result = planAction(request({ amount_sat: 1, pubkey: PUBKEY }), { - outputs: [{ amount_sat: 1, destination: { if: "something" }, id: "odd" }], - }); + const result = planAction( + action({ outputs: [{ amount_sat: 1, destination: { if: "something" }, id: "odd" }] }), + request({ amount_sat: 1, pubkey: PUBKEY }), + ); expect(result).toMatchObject({ ok: false }); }); test("refuses an action with no outputs", () => { - const result = planAction(request({ amount_sat: 1, pubkey: PUBKEY }), { outputs: [] }); + const result = planAction(action({ outputs: [] }), request({ amount_sat: 1, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: false }); }); }); describe("planAction with resolved inputs", () => { - const RECEIVE = (MANIFEST.actions as Record<string, Record<string, unknown>>).Receive; + const RECEIVE = findAction(MANIFEST, "Receive") as NormalisedAction; // Receive pays out what the covenant input holds, which the wallet reads from the chain // rather than being told. test("resolves an output amount from what an input actually holds", () => { - const result = planAction(request({ pubkey: PUBKEY }), RECEIVE, { p2pk_in: 42_000n }); + const result = planAction(RECEIVE, { + inputs: { p2pk_in: { amount_sat: 42_000n } }, + params: { pubkey: PUBKEY }, + }); expect(result).toMatchObject({ ok: true }); @@ -117,7 +121,7 @@ describe("planAction with resolved inputs", () => { }); test("refuses when the referenced input was not resolved", () => { - const result = planAction(request({ pubkey: PUBKEY }), RECEIVE, {}); + const result = planAction(RECEIVE, { inputs: {}, params: { pubkey: PUBKEY } }); expect(result).toMatchObject({ ok: false }); }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts index 6ce6fd0..f35596f 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts @@ -1,4 +1,6 @@ -import type { ParsedLiquidProcessCtParams } from "./types"; +import { asArray, asRecord } from "./json"; +import type { NormalisationNote, NormalisedAction } from "./normalise"; +import { type ReferenceScope, resolveReference } from "./references"; /** * A concrete amount the wallet worked out for one of the action's outputs. @@ -26,27 +28,22 @@ export type PlanResult = { ok: false; reason: string } | { ok: true; plan: Plann /** * Turns the action's declared outputs into concrete amounts. * - * Knowingly minimal: it resolves a literal and a `params.` reference and refuses - * everything else. The format's amounts can be arithmetic over other outputs, the fee and - * chain state, and evaluating those is a dependency graph with a fee re-pass — a later - * slice's whole subject. This exists so the thinnest real action can be built end to end, - * and it should be deleted when that slice lands rather than extended one form at a time. + * Each amount is one reference or one literal, resolved at the amount site — which accepts + * the fee, the deployment's fields, the request's parameters and arguments, a bare name and + * an attribute of a resolved input. What it still cannot do is arithmetic: the format's + * amounts can be expressions over other outputs, the fee and chain state, and evaluating + * those is a dependency graph with a fee re-pass, which is the phased-evaluation slice's + * whole subject. An expression is refused here by name rather than half-resolved. */ export function planAction( - request: ParsedLiquidProcessCtParams, - action: Record<string, unknown>, - /** - * Base units at each input the wallet already resolved, keyed by the manifest's id for - * it. These come from the chain read the covenant check already performs, so an output - * saying "as much as that input holds" resolves against what is actually there rather - * than against a figure the requester supplied. - */ - inputAmounts: Record<string, bigint> = {}, + action: NormalisedAction, + scope: ReferenceScope, + notes?: NormalisationNote[], ): PlanResult { const outputs: PlannedOutput[] = []; let fundingSats = 0n; - for (const declared of asArray(action.outputs)) { + for (const declared of asArray(action.node.outputs)) { const output = asRecord(declared); if (!output) { @@ -69,7 +66,7 @@ export function planAction( continue; } - const amount = resolveAmount(request, output.amount_sat, inputAmounts); + const amount = resolveAmount(output.amount_sat, scope, notes); if (amount === undefined) { return { @@ -93,6 +90,12 @@ export function planAction( return { ok: true, plan: { fundingSats, outputs } }; } +/** + * `change` and `wallet` are keywords rather than references, so they are read before the + * resolver is asked anything — a bare word at a destination site means one of these two, + * and the site accepts nothing else bare precisely so it cannot mean a parameter by + * accident. + */ function resolveTarget(destination: unknown): PlannedOutput["target"] | undefined { if (destination === "change") { return { kind: "change" }; @@ -107,49 +110,35 @@ function resolveTarget(destination: unknown): PlannedOutput["target"] | undefine return typeof utxoType === "string" ? { kind: "covenant", utxoType } : undefined; } -/** - * A literal, a `params.` reference to one, or `<input_id>.amount_sat`. - * - * The third form is what an action spending a covenant needs — "pay out what that input - * holds" — and it resolves against the chain, not the request. Anything else is refused - * by the caller. - */ +/** A literal, or one reference resolved at the amount site and required to be a count. */ function resolveAmount( - request: ParsedLiquidProcessCtParams, amount: unknown, - inputAmounts: Record<string, bigint>, + scope: ReferenceScope, + notes?: NormalisationNote[], ): bigint | undefined { - if (typeof amount === "number" && Number.isSafeInteger(amount)) { - return BigInt(amount); - } + const literal = asCount(amount); - if (typeof amount === "string") { - const literal = /^\d+$/.test(amount) ? BigInt(amount) : undefined; - - if (literal !== undefined) { - return literal; - } + if (literal !== undefined) { + return literal; + } - const referenced = /^\$?params\.(?<name>[A-Za-z0-9_]+)$/.exec(amount)?.groups?.name; + if (typeof amount !== "string") { + return undefined; + } - if (referenced !== undefined) { - return resolveAmount(request, request.params[referenced], inputAmounts); - } + const found = resolveReference(amount, "amount", scope, notes); - const input = /^(?<id>[A-Za-z0-9_]+)\.amount_sat$/.exec(amount)?.groups?.id; + return found.ok ? asCount(found.value) : undefined; +} - return input === undefined ? undefined : inputAmounts[input]; +function asCount(value: unknown): bigint | undefined { + if (typeof value === "bigint") { + return value; } - return undefined; -} - -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} + if (typeof value === "number") { + return Number.isSafeInteger(value) ? BigInt(value) : undefined; + } -function asRecord(value: unknown): Record<string, unknown> | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record<string, unknown>) - : undefined; + return typeof value === "string" && /^\d+$/.test(value) ? BigInt(value) : undefined; } diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts index 55af23c..37c996a 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; +import groupedManifest from "./__fixtures__/p2pk-grouped.manifest.json"; import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import { normaliseManifest } from "./normalise"; import { resolveActionRequirements } from "./requirements"; import type { ParsedLiquidProcessCtParams } from "./types"; @@ -10,6 +12,7 @@ import type { ParsedLiquidProcessCtParams } from "./types"; const SOURCE_PATH = "./p2pk.simf"; const PUBKEY = "0".repeat(64); +const MANIFEST = normaliseManifest(p2pkManifest as unknown as Record<string, unknown>).manifest; function request( overrides: Partial<ParsedLiquidProcessCtParams> = {}, @@ -24,9 +27,14 @@ function request( }; } +/** The same question asked of the published manifest, normalised once. */ +function requirements(overrides: Partial<ParsedLiquidProcessCtParams> = {}) { + return resolveActionRequirements(request(overrides), MANIFEST); +} + describe("resolveActionRequirements", () => { test("refuses an action the manifest does not declare, naming it", () => { - const { missing } = resolveActionRequirements(request({ action: "Withdraw" })); + const { missing } = requirements({ action: "Withdraw" }); expect(missing).toHaveLength(1); expect(missing[0]?.reason).toContain("Withdraw"); @@ -36,32 +44,32 @@ describe("resolveActionRequirements", () => { // It reads no deployment state, so a request without instance or state is complete. describe("Pay", () => { test("needs the contract source and the declared parameters", () => { - const { required } = resolveActionRequirements(request()); + const { required } = requirements(); expect(required).toContain("contractSources"); expect(required).toContain("params"); }); test("does not need the instance or state files", () => { - const { required } = resolveActionRequirements(request()); + const { required } = requirements(); expect(required).not.toContain("instance"); expect(required).not.toContain("state"); }); test("is complete when the source and parameters are supplied", () => { - expect(resolveActionRequirements(request()).missing).toEqual([]); + expect(requirements().missing).toEqual([]); }); test("names the contract source that was not supplied", () => { - const { missing } = resolveActionRequirements(request({ contractSources: {} })); + const { missing } = requirements({ contractSources: {} }); const entry = missing.find((item) => item.part === "contractSources"); expect(entry?.keys).toEqual([SOURCE_PATH]); }); test("names each parameter the request did not fill", () => { - const { missing } = resolveActionRequirements(request({ params: { pubkey: PUBKEY } })); + const { missing } = requirements({ params: { pubkey: PUBKEY } }); const entry = missing.find((item) => item.part === "params"); expect(entry?.keys).toEqual(["amount_sat"]); @@ -72,9 +80,7 @@ describe("resolveActionRequirements", () => { // into the state file rather than into the chain. describe("Receive", () => { const receive = (overrides: Partial<ParsedLiquidProcessCtParams> = {}) => - resolveActionRequirements( - request({ action: "Receive", params: { pubkey: PUBKEY }, ...overrides }), - ); + requirements({ action: "Receive", params: { pubkey: PUBKEY }, ...overrides }); test("needs the state file", () => { expect(receive().required).toContain("state"); @@ -91,3 +97,107 @@ describe("resolveActionRequirements", () => { }); }); }); + +// AC-10 at this seam: the grouped twin of the published manifest must be answered +// identically, so nothing downstream can behave differently on one of the two shapes. +describe("resolveActionRequirements across declaration shapes", () => { + const GROUPED = normaliseManifest(groupedManifest as unknown as Record<string, unknown>).manifest; + + for (const action of ["Pay", "Receive"]) { + test(`answers ${action} the same for a grouped manifest as for a flat one`, () => { + const params = { amount_sat: 1000, pubkey: PUBKEY }; + const flat = resolveActionRequirements(request({ action, params }), MANIFEST); + const grouped = resolveActionRequirements(request({ action, params }), GROUPED); + + expect(grouped).toEqual(flat); + }); + } +}); + +// The reference implementation's own comment calls a param's `formula` informational only +// for display and never evaluates it, so a parameter carrying one is still one the request +// has to fill. Treating it as derived accepts a short request and fails later. +describe("which parameters the request must fill", () => { + function paramsOf(declared: Record<string, unknown>) { + const manifest = normaliseManifest({ + actions: { Open: { params: declared } }, + }).manifest; + + return resolveActionRequirements( + request({ action: "Open", contractSources: {}, params: {} }), + manifest, + ); + } + + test("a parameter with a display formula is still prompted for", () => { + const { missing } = paramsOf({ total: { formula: "a + b", type: "u64" } }); + + expect(missing.find((entry) => entry.part === "params")?.keys).toEqual(["total"]); + }); + + test("a parameter the wallet supplies is not", () => { + const { missing } = paramsOf({ key: { source: { type: "wallet_key" }, type: "pubkey" } }); + + expect(missing).toEqual([]); + }); + + test("a computed parameter is not", () => { + const { missing } = paramsOf({ hash: { compute: "tapleaf", simf: "./a.simf" } }); + + expect(missing).toEqual([]); + }); +}); + +// The normalised shape keeps which class a method belongs to because only a method has a +// deployment to read. That distinction has to do work, or it is a field with no reader. +describe("which actions can read a deployment", () => { + const reader = { destination: { compile_params: { OWNER: "instance.OWNER" }, utxo_type: "v" } }; + const utxo_types = { v: { script: { source: "./v.simf" } } }; + + function ask(raw: Record<string, unknown>, action: string) { + return resolveActionRequirements( + request({ action, contractSources: { "./v.simf": "fn main() {}" }, params: {} }), + normaliseManifest(raw).manifest, + ); + } + + test("a method inside a class needs the instance file", () => { + const { missing, required } = ask( + { classes: { Vault: { methods: { Open: { outputs: [reader] } } } }, utxo_types }, + "Open", + ); + + expect(required).toContain("instance"); + expect(missing.find((entry) => entry.part === "instance")?.reason).toContain( + "deployment's field values", + ); + }); + + test("the same method is satisfied once the instance file is supplied", () => { + const manifest = normaliseManifest({ + classes: { Vault: { methods: { Open: { outputs: [reader] } } } }, + utxo_types, + }).manifest; + const { missing } = resolveActionRequirements( + request({ + action: "Open", + contractSources: { "./v.simf": "fn main() {}" }, + instance: { instance: { fields: { OWNER: "0x01" } } }, + params: {}, + }), + manifest, + ); + + expect(missing).toEqual([]); + }); + + // A free action reaching for a deployment cannot be satisfied by any request, so the + // refusal names the document's fault rather than asking for a file that would not help. + test("a free action reading a deployment is refused as a fault in the manifest", () => { + const { missing } = ask({ actions: { Open: { outputs: [reader] } }, utxo_types }, "Open"); + const entry = missing.find((part) => part.part === "instance"); + + expect(entry?.reason).toContain("not declared inside a class"); + expect(entry?.keys).toContain("action Open / output (unnamed) / OWNER"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts b/apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts index 0310d1e..5f8f398 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts @@ -1,3 +1,7 @@ +import { asRecord } from "./json"; +import { findAction, type NormalisedManifest } from "./normalise"; +import { instanceReferences } from "./references"; +import { covenantSites, namedUtxoTypes } from "./sites"; import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } from "./types"; /** @@ -9,15 +13,16 @@ import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } fro * rather than spends reads nothing from state. Requiring all six parts of every request * would refuse valid ones; requiring none would fail later and less legibly. * - * Scope note: this walks the action looking for four things — referenced contract sources, - * `instance.` references, state-file lookups, and declared parameters. It is deliberately - * not a general construct registry; that is a later slice's job, and this should be - * replaced by it rather than grown. + * Every question it asks now goes through the runtime core — the action comes from the + * normalised document, the covenant sites from one enumeration, and whether the instance + * file is read from the reference sites rather than from a search for reference-shaped + * text anywhere in the action. */ export function resolveActionRequirements( request: ParsedLiquidProcessCtParams, + manifest: NormalisedManifest, ): ActionRequirements { - const action = findAction(request.manifest, request.action); + const action = findAction(manifest, request.action); if (!action) { return { @@ -34,7 +39,7 @@ export function resolveActionRequirements( const required: ActionRequirements["required"] = []; const missing: MissingPart[] = []; - const sources = referencedContractSources(request.manifest, action); + const sources = referencedContractSources(manifest, action); if (sources.length > 0) { required.push("contractSources"); @@ -50,7 +55,7 @@ export function resolveActionRequirements( } } - const params = declaredParams(action); + const params = promptedParams(action.node); const unfilled = params.filter((name) => !(name in request.params)); if (params.length > 0) { @@ -65,10 +70,24 @@ export function resolveActionRequirements( }); } - if (referencesInstance(action)) { + const readsDeployment = instanceReferences(manifest, action); + + if (readsDeployment.length > 0) { required.push("instance"); - if (!request.instance) { + // A deployment's field values belong to a class, so an action declared outside one + // has none to read. No instance file the request could send would satisfy it, which + // makes this a fault in the document rather than a part the request left out — and + // saying so beats asking for a file that cannot help. + if (!action.boundTo) { + missing.push({ + keys: readsDeployment.map((occurrence) => occurrence.at), + part: "instance", + reason: + `"${request.action}" is not declared inside a class, so it has no deployment, ` + + "yet it reads one's field values.", + }); + } else if (!request.instance) { missing.push({ part: "instance", reason: "The action reads this deployment's field values.", @@ -76,7 +95,7 @@ export function resolveActionRequirements( } } - if (readsState(action)) { + if (spendsCovenant(action)) { required.push("state"); if (!request.state) { @@ -90,49 +109,15 @@ export function resolveActionRequirements( return { missing, required }; } -/** - * Finds an action by name. Manifests declare them either flat under `actions` or grouped - * as `methods` inside a class, and a file may carry both; the two are structurally - * identical, so either spelling resolves here. - */ -function findAction( - manifest: Record<string, unknown>, - name: string, -): Record<string, unknown> | undefined { - const flat = asRecord(manifest.actions)?.[name]; - - if (isRecord(flat)) { - return flat; - } - - const classes = asRecord(manifest.classes); - - for (const declared of Object.values(classes ?? {})) { - const method = asRecord(asRecord(declared)?.methods)?.[name]; - - if (isRecord(method)) { - return method; - } - } - - return undefined; -} - /** Contract source paths the action reaches, through the utxo types it names. */ function referencedContractSources( - manifest: Record<string, unknown>, - action: Record<string, unknown>, + manifest: NormalisedManifest, + action: Parameters<typeof namedUtxoTypes>[0], ): string[] { - const utxoTypes = asRecord(manifest.utxo_types) ?? {}; - const named = new Set(collectStrings(action, "utxo_type")); const paths = new Set<string>(); - for (const [name, declared] of Object.entries(utxoTypes)) { - if (!named.has(name)) { - continue; - } - - const source = asRecord(asRecord(declared)?.script)?.source; + for (const name of namedUtxoTypes(action)) { + const source = asRecord(asRecord(manifest.utxoTypes[name])?.script)?.source; if (typeof source === "string") { paths.add(source); @@ -142,92 +127,29 @@ function referencedContractSources( return [...paths]; } -/** Parameter names the action declares and therefore expects the request to fill. */ -function declaredParams(action: Record<string, unknown>): string[] { +/** + * Parameter names the request has to fill. + * + * A parameter the wallet or the protocol supplies is not prompted for: `source` names + * where the value comes from, and `compute` and `derived` say it is worked out rather than + * entered. `formula` is **not** one of these — the reference implementation's own comment + * calls it informational only for display and never evaluates it, so a parameter carrying + * one is still a parameter the request must fill. Treating it as derived accepts a request + * that is short a value and fails later, further from the cause. + */ +function promptedParams(action: Record<string, unknown>): string[] { const params = asRecord(action.params) ?? {}; - // A param carrying a `source` or a `formula` is derived rather than prompted for. return Object.entries(params) .filter(([, declared]) => { const record = asRecord(declared); - return !record || (!("source" in record) && !("formula" in record)); + return !record || !("source" in record || "compute" in record || "derived" in record); }) .map(([name]) => name); } -/** Whether the action reads this deployment's field values under either spelling. */ -function referencesInstance(action: Record<string, unknown>): boolean { - return collectStringValues(action).some( - (value) => /(^|\$)instance\./.test(value) || /(^|\$)compile_params\./.test(value), - ); -} - /** Whether the action spends a covenant UTXO, which is a lookup into the state file. */ -function readsState(action: Record<string, unknown>): boolean { - const inputs = Array.isArray(action.inputs) ? action.inputs : []; - - return inputs.some((input) => { - const source = asRecord(input)?.utxo_source; - - return isRecord(source) && "utxo_type" in source; - }); -} - -/** Every string value under `key`, at any depth. */ -function collectStrings(value: unknown, key: string): string[] { - const found: string[] = []; - - walk(value, (node) => { - const candidate = node[key]; - - if (typeof candidate === "string") { - found.push(candidate); - } - }); - - return found; -} - -/** Every string value at any depth, used to spot reference-shaped text. */ -function collectStringValues(value: unknown): string[] { - const found: string[] = []; - - walk(value, (node) => { - for (const entry of Object.values(node)) { - if (typeof entry === "string") { - found.push(entry); - } - } - }); - - return found; -} - -function walk(value: unknown, visit: (node: Record<string, unknown>) => void): void { - if (Array.isArray(value)) { - for (const entry of value) { - walk(entry, visit); - } - - return; - } - - if (!isRecord(value)) { - return; - } - - visit(value); - - for (const entry of Object.values(value)) { - walk(entry, visit); - } -} - -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function asRecord(value: unknown): Record<string, unknown> | undefined { - return isRecord(value) ? value : undefined; +function spendsCovenant(action: Parameters<typeof covenantSites>[0]): boolean { + return covenantSites(action).some((site) => site.role === "spent"); } diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts index f6486f8..2572a4a 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import groupedManifest from "./__fixtures__/p2pk-grouped.manifest.json"; import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; import { isRefusal, reviewManifestAction } from "./review"; import type { ParsedLiquidProcessCtParams } from "./types"; @@ -235,3 +236,73 @@ describe("reviewManifestAction", () => { expect(isRefusal(result)).toBe(true); }); }); + +// The runtime core, observed where it actually matters: at the seam the wallet method +// calls, not only in the units beneath it. +describe("reviewManifestAction reads through the runtime core", () => { + const grouped = (overrides: Partial<ParsedLiquidProcessCtParams> = {}) => + request({ + manifest: groupedManifest as unknown as Record<string, unknown>, + ...overrides, + }); + + // AC-10 at the review seam: the grouped twin of the published manifest is reviewed + // into the same transaction, so nothing a person is shown depends on which shape the + // site chose. + test("reviews a grouped manifest into the same result as the flat one", async () => { + const flat = await reviewManifestAction(request(), { ...deps, readTxOut: readTxOut("unused") }); + const fromGrouped = await reviewManifestAction(grouped(), { + ...deps, + readTxOut: readTxOut("unused"), + }); + + expect(isRefusal(fromGrouped)).toBe(false); + + if (!isRefusal(flat) && !isRefusal(fromGrouped)) { + expect(fromGrouped.covenants).toEqual(flat.covenants); + expect(fromGrouped.outputs).toEqual(flat.outputs); + expect(fromGrouped.selected).toEqual(flat.selected); + } + }); + + test("reports the legacy spelling the grouped document used", async () => { + const result = await reviewManifestAction(grouped(), { + ...deps, + readTxOut: readTxOut("unused"), + }); + + if (!isRefusal(result)) { + expect(result.normalisation).toContainEqual({ + at: "manifest", + canonical: "manifest_version", + found: "compose_version", + }); + } + }); + + // AC-02, decorative half: the published manifest carries attestation_version, which no + // implementation reads. Ignoring it is right; ignoring it without saying so is not. + test("records the constructs it ignored rather than dropping them", async () => { + const result = await reviewManifestAction(request(), { + ...deps, + readTxOut: readTxOut("unused"), + }); + + if (!isRefusal(result)) { + expect(result.ignoredConstructs.map((finding) => finding.key)).toContain( + "attestation_version", + ); + } + }); + + test("keeps a load-bearing construct out of the ignored list", async () => { + const result = await reviewManifestAction(request(), { + ...deps, + readTxOut: readTxOut("unused"), + }); + + if (!isRefusal(result)) { + expect(result.ignoredConstructs.map((finding) => finding.key)).not.toContain("validations"); + } + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts index c0ce229..42be55c 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -1,8 +1,18 @@ import { encodeExplicitTxOut, type ReadFeeRate, type ReadTxOut } from "./chainRead"; import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; import { type CompileCovenant, covenantMatchesChain, deriveCovenantAddress } from "./covenant"; +import { asArray, asRecord } from "./json"; +import { + findAction, + type NormalisationNote, + normaliseInstance, + normaliseManifest, +} from "./normalise"; import { planAction } from "./plan"; +import type { ReferenceScope } from "./references"; +import { type ConstructFinding, ignored, inspectConstructs } from "./registry"; import { resolveActionRequirements } from "./requirements"; +import { covenantSites } from "./sites"; import type { ParsedLiquidProcessCtParams } from "./types"; /** @@ -55,6 +65,16 @@ export type ManifestReview = { covenantInputs: ReviewedCovenantInput[]; /** What the wallet will pay, established from the chain rather than from the request. */ feeRateSatsPerKvb: number; + /** + * Constructs the manifest carries that this runtime did not act on and did not need to. + * + * Recorded rather than dropped: a construct that changes nothing still tells a reader + * which parts of a document the wallet did not read, and a wallet that ignores something + * silently is indistinguishable from one that missed it. + */ + ignoredConstructs: ConstructFinding[]; + /** Legacy spellings the document used, so the generation it came from can be reported. */ + normalisation: NormalisationNote[]; outputs: ReviewedOutput[]; protocol: string; /** The wallet's own outputs that fund this, chosen by the wallet. */ @@ -75,6 +95,11 @@ export function isRefusal(result: ReviewManifestActionResult): result is ReviewR * Runs before the permission gate deliberately: a standing permission skips the prompt, * so this is the only thing between a request and a signature. Everything it cannot * establish is a refusal — there is no return value that means "probably fine". + * + * The document is normalised once, here, and everything downstream reads that rather than + * the request's raw JSON. That is what makes the two declaration shapes and the two + * reference namespaces indistinguishable to the rest of the wallet instead of a condition + * each reader has to remember. */ export async function reviewManifestAction( request: ParsedLiquidProcessCtParams, @@ -88,7 +113,12 @@ export async function reviewManifestAction( walletScriptPubKeyHex: string; }, ): Promise<ReviewManifestActionResult> { - const requirements = resolveActionRequirements(request); + const normalised = normaliseManifest(request.manifest); + const manifest = normalised.manifest; + const deployment = normaliseInstance(request.instance); + const notes: NormalisationNote[] = [...normalised.notes, ...deployment.notes]; + + const requirements = resolveActionRequirements(request, manifest); if (requirements.missing.length > 0) { const named = requirements.missing @@ -98,23 +128,32 @@ export async function reviewManifestAction( return { reason: `This request cannot be built. ${named}`, refused: true }; } - const action = findAction(request); + const action = findAction(manifest, request.action); if (!action) { return { reason: `The manifest declares no action named "${request.action}".`, refused: true }; } - const declaredTypes = declaredParamTypes(action); + const declaredTypes = declaredParamTypes(action.node); const covenants: CovenantFinding[] = []; - /** What each covenant input actually holds, read from the chain rather than told. */ - const inputAmounts: Record<string, bigint> = {}; const covenantInputs: ReviewedCovenantInput[] = []; + /** What each covenant input actually holds, read from the chain rather than told. */ + const inputs: Record<string, Record<string, unknown>> = {}; + + const scope: ReferenceScope = { + inputs, + instance: deployment.instance.fields, + params: request.params, + }; for (const site of covenantSites(action)) { - const derived = await deriveCovenantAddress(request, { + const derived = await deriveCovenantAddress(manifest, { compile: input.compile, + contractSources: request.contractSources, declaredTypes, network: input.network, + notes, + scope, utxoType: site.utxoType, wiring: site.wiring, }); @@ -161,7 +200,7 @@ export async function reviewManifestAction( } if (onChain.amountSats !== undefined && site.id) { - inputAmounts[site.id] = BigInt(onChain.amountSats); + inputs[site.id] = { amount_sat: BigInt(onChain.amountSats) }; } const txOutHex = encodeExplicitTxOut(onChain); @@ -191,7 +230,7 @@ export async function reviewManifestAction( }); } - const plan = planAction(request, action, inputAmounts); + const plan = planAction(action, scope, notes); if (!plan.ok) { return { reason: plan.reason, refused: true }; @@ -248,8 +287,10 @@ export async function reviewManifestAction( covenantInputs, covenants, feeRateSatsPerKvb, + ignoredConstructs: ignored(inspectConstructs(manifest)), + normalisation: notes, outputs, - protocol: typeof request.manifest.protocol === "string" ? request.manifest.protocol : "", + protocol: manifest.protocol ?? "", selected: selection.selected, }; } @@ -257,61 +298,6 @@ export async function reviewManifestAction( /** Confirmation target for the fee estimate, in blocks. */ const FEE_TARGET_BLOCKS = 6; -type CovenantSite = { - /** The manifest's id for this input or output, which its amounts refer to it by. */ - id: string; - role: "created" | "spent"; - utxoType: string; - wiring: Record<string, unknown>; -}; - -/** - * Every place in the action where a covenant appears, and which side it is on. - * - * Inputs spend a covenant, outputs create one. The distinction decides whether there is - * anything on chain to compare the derived address against. - */ -function covenantSites(action: Record<string, unknown>): CovenantSite[] { - const sites: CovenantSite[] = []; - - for (const entry of asArray(action.inputs)) { - const site = covenantReference(asRecord(entry)?.utxo_source); - - if (site) { - sites.push({ ...site, id: identifierOf(entry), role: "spent" }); - } - } - - for (const entry of asArray(action.outputs)) { - const site = covenantReference(asRecord(entry)?.destination); - - if (site) { - sites.push({ ...site, id: identifierOf(entry), role: "created" }); - } - } - - return sites; -} - -function identifierOf(entry: unknown): string { - const id = asRecord(entry)?.id; - - return typeof id === "string" ? id : ""; -} - -function covenantReference( - value: unknown, -): { utxoType: string; wiring: Record<string, unknown> } | undefined { - const record = asRecord(value); - const utxoType = record?.utxo_type; - - if (typeof utxoType !== "string") { - return undefined; - } - - return { utxoType, wiring: asRecord(record?.compile_params) ?? {} }; -} - function findStateOutpoint( request: ParsedLiquidProcessCtParams, utxoType: string, @@ -331,24 +317,6 @@ function findStateOutpoint( return undefined; } -function findAction(request: ParsedLiquidProcessCtParams): Record<string, unknown> | undefined { - const flat = asRecord(asRecord(request.manifest.actions)?.[request.action]); - - if (flat) { - return flat; - } - - for (const declared of Object.values(asRecord(request.manifest.classes) ?? {})) { - const method = asRecord(asRecord(asRecord(declared)?.methods)?.[request.action]); - - if (method) { - return method; - } - } - - return undefined; -} - function declaredParamTypes(action: Record<string, unknown>): Record<string, string> { const types: Record<string, string> = {}; @@ -362,13 +330,3 @@ function declaredParamTypes(action: Record<string, unknown>): Record<string, str return types; } - -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function asRecord(value: unknown): Record<string, unknown> | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record<string, unknown>) - : undefined; -} From 87108ecbea38916991fd2a55f5789f8f5ac23293 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 14:14:46 +0300 Subject: [PATCH 036/124] test(liquid): prove the two manifest shapes reach the same transaction A protocol written in grouped classes with the older top-level spelling goes through parse, verify, plan and sign and comes out as the transaction its flat twin produces. The domain tests show the shapes converge; this shows nothing between them and a signature reintroduces a difference. --- .../index.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index d98e88a..30aee12 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import groupedManifest from "../../../domain/manifest/__fixtures__/p2pk-grouped.manifest.json"; import p2pkManifest from "../../../domain/manifest/__fixtures__/p2pk.manifest.json"; import { createProcessLiquidConfidentialTransaction, @@ -166,3 +167,30 @@ describe("processLiquidConfidentialTransaction", () => { await expect(method(params({ action: "Withdraw" }), context())).rejects.toThrow(/Withdraw/); }); }); + +// AC-10 end to end: the same protocol written in the grouped shape with the older +// top-level spelling goes through the whole method and produces the same transaction. +describe("processLiquidConfidentialTransaction across declaration shapes", () => { + test("builds and signs a grouped manifest exactly as it does a flat one", async () => { + const flat = await subject().method(params(), context()); + const grouped = await subject().method(params({ manifest: groupedManifest }), context()); + + expect(grouped).toEqual(flat); + }); + + test("finds a method declared inside a class by its own name", async () => { + const { method, recorded } = subject(); + + await method( + params({ + action: "Receive", + manifest: groupedManifest, + params: { pubkey: PUBKEY }, + state: { utxos: [{ txid: "a".repeat(64), utxo_type: "p2pk_output", vout: 0 }] }, + }), + context(), + ); + + expect(recorded.mnemonicCalls).toBe(1); + }); +}); From 1b53f829fc6d13e465930e31ee0df9b29b200e59 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 15:52:15 +0300 Subject: [PATCH 037/124] feat(liquid): make an output amount an expression rather than one reference The format's amounts are arithmetic over parameters, the fee and what an input holds. The format defines none of that arithmetic; the reference implementation hands it to a third-party crate's signed 64-bit mode, so signed intermediates and truncating division are what real manifests were written against and are matched here. Three things that crate decides and no specification does are decided here instead, because inheriting them by accident is how amounts diverge silently: leaving the 64-bit range is an error rather than a wrap, dividing by zero is an error rather than any particular value, and a negative exponent is an error rather than a call left unexpanded to fail somewhere less informative. --- .../liquid/domain/manifest/evaluate.test.ts | 153 ++++++++ .../chains/liquid/domain/manifest/evaluate.ts | 344 ++++++++++++++++++ .../liquid/domain/manifest/plan.test.ts | 65 +++- .../chains/liquid/domain/manifest/plan.ts | 41 ++- 4 files changed, 584 insertions(+), 19 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/evaluate.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.test.ts new file mode 100644 index 0000000..290ca1b --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; + +import { evaluateExpression } from "./evaluate"; +import type { ReferenceScope } from "./references"; + +// The format defines no arithmetic. What real manifests were authored against is the +// reference implementation's behaviour, recorded in the change bundle's inventory: it +// expands pow(), substitutes references textually and hands the result to a third-party +// crate's signed 64-bit integer mode, checking only the final value for being negative. +// Where that behaviour is defined we match it; where it is inherited from a crate we do +// not use, we choose deliberately and the choice is asserted here rather than assumed. + +const SCOPE: ReferenceScope = { + fee: 500n, + inputs: { will_in: { amount_sat: 100_000n } }, + instance: { SHARE: 3n }, + params: { amount_sat: 21_000n, decimals: 8n }, +}; + +function value(text: string, scope: ReferenceScope = SCOPE): bigint | string { + const result = evaluateExpression(text, "amount", scope); + + return result.ok ? result.value : result.reason; +} + +describe("evaluateExpression", () => { + describe("arithmetic", () => { + test("a bare literal", () => { + expect(value("42")).toBe(42n); + }); + + test("addition and subtraction, left to right", () => { + expect(value("10 - 3 - 2")).toBe(5n); + }); + + test("multiplication binds tighter than addition", () => { + expect(value("2 + 3 * 4")).toBe(14n); + }); + + test("parentheses override precedence", () => { + expect(value("(2 + 3) * 4")).toBe(20n); + }); + + test("division truncates toward zero, as a 64-bit integer division does", () => { + expect(value("7 / 2")).toBe(3n); + expect(value("0 - 7 / 2")).toBe(-3n); + }); + + test("remainder takes the sign of the dividend", () => { + expect(value("0 - 7 % 3")).toBe(-1n); + }); + + test("unary minus", () => { + expect(value("-5 + 8")).toBe(3n); + }); + + test("pow expands, and its exponent may itself be a reference", () => { + expect(value("pow(10, params.decimals)")).toBe(100_000_000n); + }); + }); + + describe("references resolve through the same sites as everything else", () => { + test("a parameter", () => { + expect(value("params.amount_sat")).toBe(21_000n); + }); + + test("an attribute of a resolved input, minus the wallet's fee", () => { + expect(value("will_in.amount_sat - fee")).toBe(99_500n); + }); + + test("a deployment field", () => { + expect(value("instance.SHARE * 2")).toBe(6n); + }); + + test("a decimal string resolves as the count it spells", () => { + expect(value("total + 1", { params: { total: "9007199254740993" } })).toBe( + 9_007_199_254_740_994n, + ); + }); + + // A compile parameter decides a covenant address, and the fee comes from the + // transaction that pays to that address, so the site refuses the term rather than + // the evaluator refusing the arithmetic. + test("the site still decides which forms are legal", () => { + const result = evaluateExpression("fee + 1", "compileParam", SCOPE); + + expect(result.ok).toBe(false); + }); + }); + + // Intermediate negatives are legal because the reference only checks the final value. + describe("signed intermediates", () => { + test("an intermediate may go negative and come back", () => { + expect(value("params.amount_sat - 30000 + 10000")).toBe(1_000n); + }); + + test("a negative result is returned rather than refused, because the caller decides", () => { + expect(value("10 - 20")).toBe(-10n); + }); + }); + + // The reference inherits these from a Rust crate we do not use. Matching a crate is + // not automatic, so the behaviour is chosen: anything that would leave the 64-bit + // range is an error, never a wrap, because an amount that wraps silently changes what + // is paid. + describe("what we chose rather than inherited", () => { + test("overflow past the 64-bit range is an error, not a wrap", () => { + expect(value("9223372036854775807 + 1")).toContain("64-bit"); + }); + + test("an intermediate outside the range is an error even when the result would fit", () => { + expect(value("9223372036854775807 * 2 / 2")).toContain("64-bit"); + }); + + test("division by zero is an error", () => { + expect(value("10 / 0")).toContain("zero"); + }); + + test("remainder by zero is an error", () => { + expect(value("10 % 0")).toContain("zero"); + }); + + test("a negative exponent is an error rather than a silent no-op", () => { + expect(value("pow(2, 0 - 1)")).toContain("negative"); + }); + }); + + describe("what it refuses", () => { + test("an unresolvable term, naming it", () => { + expect(value("nowhere + 1")).toContain("nowhere"); + }); + + test("a malformed expression, rather than evaluating part of it", () => { + expect(value("1 +")).toContain("1 +"); + }); + + test("an unbalanced parenthesis", () => { + expect(value("(1 + 2")).toContain("(1 + 2"); + }); + + test("an operator this runtime does not define", () => { + expect(value("1 & 2")).toContain("1 & 2"); + }); + + test("a function that is not pow", () => { + expect(value("min(1, 2)")).toContain("min"); + }); + + test("a term that resolves to something that is not a number", () => { + expect(value("owner + 1", { params: { owner: "0xdeadbeef" } })).toContain("owner"); + }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts b/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts new file mode 100644 index 0000000..6a68344 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts @@ -0,0 +1,344 @@ +import type { NormalisationNote } from "./normalise"; +import { type ReferenceScope, type ReferenceSiteKind, resolveReference } from "./references"; + +export type EvaluationResult = { ok: true; value: bigint } | { ok: false; reason: string }; + +/** + * The range every value in an expression must stay inside. + * + * The format defines no arithmetic. The reference implementation hands expressions to a + * third-party crate's signed 64-bit integer mode, so signed 64-bit is the range real + * manifests were authored against and matching it is not a choice. What that crate does at + * the edges is a choice, and it is made below. + */ +const I64_MAX = 2n ** 63n - 1n; +const I64_MIN = -(2n ** 63n); + +/** + * Evaluates one amount expression against the values available at a site. + * + * Three properties are inherited from the behaviour real manifests were written against, + * and are not ours to vary: arithmetic is signed rather than unsigned, so an intermediate + * may go negative and come back; division truncates toward zero; and only the caller checks + * the final value for being negative, because an amount and a difference are different + * questions. + * + * Three are ours, because the reference inherits them from a Rust crate this runtime does + * not use, and inheriting them by accident is how amounts diverge silently: + * + * - **Leaving the 64-bit range is an error, never a wrap.** A wrapped amount is a + * different amount, and nothing downstream could tell. + * - **Division or remainder by zero is an error**, rather than any particular value. + * - **A negative exponent is an error**, where the reference leaves the call unexpanded and + * carries on with text that then fails to parse somewhere less informative. + * + * Every term resolves through the same site table as a bare reference, so a term that is + * illegal at this position is refused as a position error rather than as arithmetic. + */ +export function evaluateExpression( + text: string, + site: ReferenceSiteKind, + scope: ReferenceScope, + notes?: NormalisationNote[], +): EvaluationResult { + const tokens = tokenise(text); + + if (!tokens.ok) { + return unreadable(text); + } + + const reader = { at: 0, notes, scope, site, tokens: tokens.tokens }; + const value = readSum(reader); + + if (!value.ok) { + // Every refusal carries the expression it came from. The reader of this message is a + // person deciding whether to trust a site, and "this amount divides by zero" without + // the amount leaves them nothing to act on or report. + return { ok: false, reason: `${value.reason} The expression was "${text}".` }; + } + + return reader.at === reader.tokens.length ? value : unreadable(text); +} + +function unreadable(text: string): EvaluationResult { + return { ok: false, reason: `"${text}" is not an expression this runtime reads.` }; +} + +type Token = { kind: "name" | "number" | "symbol"; text: string }; + +type Reader = { + at: number; + notes?: NormalisationNote[]; + scope: ReferenceScope; + site: ReferenceSiteKind; + tokens: Token[]; +}; + +const SYMBOLS = new Set(["+", "-", "*", "/", "%", "(", ")", ","]); + +function tokenise(text: string): { ok: true; tokens: Token[] } | { ok: false } { + const tokens: Token[] = []; + let at = 0; + + while (at < text.length) { + const character = text[at] ?? ""; + + if (/\s/.test(character)) { + at += 1; + + continue; + } + + if (SYMBOLS.has(character)) { + tokens.push({ kind: "symbol", text: character }); + at += 1; + + continue; + } + + // A name may carry the reference syntax — a `$` prefix and one dotted segment — so + // the whole term reaches the resolver as it was written. + const name = /^\$?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?/.exec(text.slice(at)); + + if (name) { + tokens.push({ kind: "name", text: name[0] }); + at += name[0].length; + + continue; + } + + const number = /^\d+/.exec(text.slice(at)); + + if (number) { + tokens.push({ kind: "number", text: number[0] }); + at += number[0].length; + + continue; + } + + return { ok: false }; + } + + return tokens.length > 0 ? { ok: true, tokens } : { ok: false }; +} + +function peek(reader: Reader): Token | undefined { + return reader.tokens[reader.at]; +} + +function eat(reader: Reader, text: string): boolean { + if (peek(reader)?.text === text) { + reader.at += 1; + + return true; + } + + return false; +} + +function readSum(reader: Reader): EvaluationResult { + let left = readProduct(reader); + + if (!left.ok) { + return left; + } + + for (;;) { + const operator = peek(reader); + + if (operator?.kind !== "symbol" || (operator.text !== "+" && operator.text !== "-")) { + return left; + } + + reader.at += 1; + + const right = readProduct(reader); + + if (!right.ok) { + return right; + } + + const combined = bounded( + operator.text === "+" ? left.value + right.value : left.value - right.value, + ); + + if (!combined.ok) { + return combined; + } + + left = combined; + } +} + +function readProduct(reader: Reader): EvaluationResult { + let left = readUnary(reader); + + if (!left.ok) { + return left; + } + + for (;;) { + const operator = peek(reader); + + if ( + operator?.kind !== "symbol" || + (operator.text !== "*" && operator.text !== "/" && operator.text !== "%") + ) { + return left; + } + + reader.at += 1; + + const right = readUnary(reader); + + if (!right.ok) { + return right; + } + + if (operator.text !== "*" && right.value === 0n) { + return { + ok: false, + reason: `This amount divides by zero, and no value is the right answer to that.`, + }; + } + + const combined = bounded( + operator.text === "*" + ? left.value * right.value + : operator.text === "/" + ? left.value / right.value + : left.value % right.value, + ); + + if (!combined.ok) { + return combined; + } + + left = combined; + } +} + +function readUnary(reader: Reader): EvaluationResult { + if (eat(reader, "-")) { + const inner = readUnary(reader); + + return inner.ok ? bounded(-inner.value) : inner; + } + + return readTerm(reader); +} + +function readTerm(reader: Reader): EvaluationResult { + const token = peek(reader); + + if (!token) { + return { ok: false, reason: "This amount ends where a value was expected." }; + } + + if (token.text === "(") { + reader.at += 1; + + const inner = readSum(reader); + + if (!inner.ok) { + return inner; + } + + return eat(reader, ")") + ? inner + : { ok: false, reason: "This amount opens a bracket it never closes." }; + } + + if (token.kind === "number") { + reader.at += 1; + + return bounded(BigInt(token.text)); + } + + if (token.kind !== "name") { + return { ok: false, reason: `This amount uses "${token.text}", which is not a value.` }; + } + + reader.at += 1; + + return peek(reader)?.text === "(" ? readCall(reader, token.text) : readReference(reader, token); +} + +/** + * `pow` is the only function the format has, and the reference expands it before evaluating + * rather than passing it through. Anything else named like a call is refused by name: a + * function nobody implements silently returning something is how an amount goes wrong + * without an error. + */ +function readCall(reader: Reader, name: string): EvaluationResult { + if (name !== "pow") { + return { ok: false, reason: `This amount calls "${name}", which this runtime does not have.` }; + } + + reader.at += 1; + + const base = readSum(reader); + + if (!base.ok) { + return base; + } + + if (!eat(reader, ",")) { + return { ok: false, reason: "pow takes a base and an exponent." }; + } + + const exponent = readSum(reader); + + if (!exponent.ok) { + return exponent; + } + + if (!eat(reader, ")")) { + return { ok: false, reason: "This amount opens a bracket it never closes." }; + } + + if (exponent.value < 0n) { + return { ok: false, reason: "pow was given a negative exponent, which has no whole answer." }; + } + + if (exponent.value > 63n) { + return { ok: false, reason: "pow was given an exponent beyond the 64-bit range." }; + } + + return bounded(base.value ** exponent.value); +} + +function readReference(reader: Reader, token: Token): EvaluationResult { + const found = resolveReference(token.text, reader.site, reader.scope, reader.notes); + + if (!found.ok) { + return found; + } + + const count = asInteger(found.value); + + return count === undefined + ? { + ok: false, + reason: `"${token.text}" is not a number, so it cannot be part of an amount.`, + } + : bounded(count); +} + +function asInteger(value: unknown): bigint | undefined { + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number") { + return Number.isSafeInteger(value) ? BigInt(value) : undefined; + } + + return typeof value === "string" && /^-?\d+$/.test(value) ? BigInt(value) : undefined; +} + +function bounded(value: bigint): EvaluationResult { + return value > I64_MAX || value < I64_MIN + ? { ok: false, reason: "This amount leaves the 64-bit range these numbers are held in." } + : { ok: true, value }; +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts index 19f1c08..2364229 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts @@ -61,7 +61,7 @@ describe("planAction", () => { } }); - test("refuses an amount it cannot evaluate rather than assuming one", () => { + test("refuses an amount whose terms it cannot resolve rather than assuming one", () => { const result = planAction( PAY, request({ amount_sat: "will_in.amount_sat - fee", pubkey: PUBKEY }), @@ -126,3 +126,66 @@ describe("planAction with resolved inputs", () => { expect(result).toMatchObject({ ok: false }); }); }); + +// Amounts are expressions, not single references. The fee is one term among the others, +// which is what lets a draft be planned against zero and re-planned against an estimate. +describe("planAction over expressions", () => { + const RECEIVE = findAction(MANIFEST, "Receive") as NormalisedAction; + + function payOut(amount: unknown, scope: Partial<ReferenceScope> = {}) { + return planAction( + action({ outputs: [{ amount_sat: amount, destination: "wallet", id: "out" }] }), + { + params: { pubkey: PUBKEY }, + ...scope, + }, + ); + } + + test("pays what an input holds, less the wallet's fee", () => { + const result = payOut("p2pk_in.amount_sat - fee", { + fee: 500n, + inputs: { p2pk_in: { amount_sat: 42_000n } }, + }); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.plan.outputs[0]?.sats).toBe(41_500n); + } + }); + + // The same action planned twice against two fees is how the re-pass works: the fee is a + // value in the scope, so nothing about the amount has to be re-parsed to change it. + test("the same expression follows the fee it is given", () => { + const scope = { inputs: { p2pk_in: { amount_sat: 42_000n } } }; + const draft = payOut("p2pk_in.amount_sat - fee", { ...scope, fee: 0n }); + const priced = payOut("p2pk_in.amount_sat - fee", { ...scope, fee: 500n }); + + expect(draft.ok && draft.plan.outputs[0]?.sats).toBe(42_000n); + expect(priced.ok && priced.plan.outputs[0]?.sats).toBe(41_500n); + }); + + test("refuses an amount referencing the fee before the wallet has one", () => { + const result = payOut("p2pk_in.amount_sat - fee", { + inputs: { p2pk_in: { amount_sat: 42_000n } }, + }); + + expect(result).toMatchObject({ ok: false }); + }); + + test("carries the expression into the refusal, so a person can act on it", () => { + const result = payOut("nowhere * 2"); + + expect(result.ok ? "" : result.reason).toContain("nowhere * 2"); + }); + + test("still plans the published action, which needs no arithmetic", () => { + const result = planAction(RECEIVE, { + inputs: { p2pk_in: { amount_sat: 42_000n } }, + params: { pubkey: PUBKEY }, + }); + + expect(result).toMatchObject({ ok: true }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts index f35596f..b8b30db 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts @@ -1,6 +1,7 @@ +import { evaluateExpression } from "./evaluate"; import { asArray, asRecord } from "./json"; import type { NormalisationNote, NormalisedAction } from "./normalise"; -import { type ReferenceScope, resolveReference } from "./references"; +import type { ReferenceScope } from "./references"; /** * A concrete amount the wallet worked out for one of the action's outputs. @@ -28,12 +29,11 @@ export type PlanResult = { ok: false; reason: string } | { ok: true; plan: Plann /** * Turns the action's declared outputs into concrete amounts. * - * Each amount is one reference or one literal, resolved at the amount site — which accepts - * the fee, the deployment's fields, the request's parameters and arguments, a bare name and - * an attribute of a resolved input. What it still cannot do is arithmetic: the format's - * amounts can be expressions over other outputs, the fee and chain state, and evaluating - * those is a dependency graph with a fee re-pass, which is the phased-evaluation slice's - * whole subject. An expression is refused here by name rather than half-resolved. + * Each amount is a literal or an expression evaluated at the amount site, which accepts the + * fee, the deployment's fields, the request's parameters and arguments, a bare name and an + * attribute of a resolved input. `fee` resolves to whatever the scope carries, so planning + * a draft against a fee of zero and re-planning against an estimate is a matter of calling + * this twice with different scopes rather than of a second code path. */ export function planAction( action: NormalisedAction, @@ -68,19 +68,22 @@ export function planAction( const amount = resolveAmount(output.amount_sat, scope, notes); - if (amount === undefined) { + if (!amount.ok) { return { ok: false, - reason: `Output ${id || "(unnamed)"} has an amount this runtime does not evaluate yet.`, + reason: `Output ${id || "(unnamed)"} cannot be paid: ${amount.reason}`, }; } - if (amount <= 0n) { + // The evaluator returns a signed value because an expression may legitimately go + // negative on the way; an output that lands there pays nothing and is refused here, + // which is where the question is about an amount rather than about arithmetic. + if (amount.sats <= 0n) { return { ok: false, reason: `Output ${id || "(unnamed)"} would pay nothing.` }; } - fundingSats += amount; - outputs.push({ id, sats: amount, target }); + fundingSats += amount.sats; + outputs.push({ id, sats: amount.sats, target }); } if (outputs.length === 0) { @@ -110,25 +113,27 @@ function resolveTarget(destination: unknown): PlannedOutput["target"] | undefine return typeof utxoType === "string" ? { kind: "covenant", utxoType } : undefined; } -/** A literal, or one reference resolved at the amount site and required to be a count. */ +/** A literal, or an expression evaluated at the amount site. */ function resolveAmount( amount: unknown, scope: ReferenceScope, notes?: NormalisationNote[], -): bigint | undefined { +): { ok: false; reason: string } | { ok: true; sats: bigint } { const literal = asCount(amount); if (literal !== undefined) { - return literal; + return { ok: true, sats: literal }; } if (typeof amount !== "string") { - return undefined; + return { ok: false, reason: "its amount is neither a number nor an expression" }; } - const found = resolveReference(amount, "amount", scope, notes); + const evaluated = evaluateExpression(amount, "amount", scope, notes); - return found.ok ? asCount(found.value) : undefined; + return evaluated.ok + ? { ok: true, sats: evaluated.value } + : { ok: false, reason: evaluated.reason }; } function asCount(value: unknown): bigint | undefined { From 8509fa1756bde232ccad46db6bdd78cc9f6fafd6 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 15:52:18 +0300 Subject: [PATCH 038/124] feat(liquid): settle the covenant hashes a manifest works out for itself A covenant's parameters can hold another covenant's script hash, so there is no order to evaluate them in: every computed value starts at thirty-two zero bytes, all of them are recomputed together, and the round that reproduces its own input is the answer. Exceeding the bound fails, because the alternative is paying to an address derived from values that never agreed with themselves. What converges is a chain that only appears circular. Auto-populate hands every parameter to every covenant, and depends_on narrows that to what a contract actually consumes; a pair that genuinely consumes each other has no fixed point and the bound is what catches it. A computed hash needs no declared type: the compiler's Pubkey, Message, Scalar, Fe, ExplicitAsset and ExplicitNonce all resolve to the same U256, so thirty-two bytes encode identically whatever a contract calls them. --- .../liquid/domain/manifest/compileParams.ts | 6 + .../liquid/domain/manifest/computed.test.ts | 237 ++++++++++++++++++ .../chains/liquid/domain/manifest/computed.ts | 237 ++++++++++++++++++ .../domain/manifest/covenantHash.test.ts | 48 ++++ .../liquid/domain/manifest/covenantHash.ts | 20 ++ 5 files changed, 548 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/computed.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts index 1a57638..c38b059 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts @@ -22,6 +22,12 @@ export type ResolveCompileParamsResult = * `liquid.asset_id` and `address` — arrive with the slices that need them. */ const PARAM_TYPES: Record<string, string> = { + // A covenant script hash is thirty-two bytes. `u256` rather than one of the compiler's + // aliases because they are the same type: `Pubkey`, `Message`, `Scalar`, `Fe`, + // `ExplicitAsset` and `ExplicitNonce` all resolve to `U256` in simplicityhl 0.6.0 + // (`src/types.rs` L863-865), so the encoded value does not depend on which name a + // contract happens to use for it. + bytes32: "u256", pubkey: "Pubkey", }; diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts new file mode 100644 index 0000000..2bd8cdf --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "bun:test"; + +import { COVENANT_HASH_SEED, ITERATION_BOUND, resolveComputedParams } from "./computed"; +import { normaliseManifest } from "./normalise"; + +// A covenant's compile parameters can hold another covenant's script hash, and that other +// covenant's parameters can hold the first's — which the reference implementation resolves +// by seeding with 32 zero bytes and iterating. AC-08 requires those values to reach a stable +// answer inside a bound, and requires exceeding the bound to fail the action rather than to +// proceed on whatever the last round produced. + +const SOURCES = { "./a.simf": "fn main() { }", "./b.simf": "fn main() { }" }; + +/** + * Stands in for compiling a contract and hashing its scriptPubKey. It is a real function of + * its arguments — the same arguments give the same answer and different ones differ — which + * is the only property convergence depends on. + */ +function compiler() { + const calls: { argumentsJson: string; source: string }[] = []; + + return { + calls, + hashCovenant: ({ argumentsJson, source }: { argumentsJson: string; source: string }) => { + calls.push({ argumentsJson, source }); + + let hash = 0n; + + for (const code of `${source}${argumentsJson}`) { + hash = (hash * 31n + BigInt(code.codePointAt(0) ?? 0)) % 2n ** 64n; + } + + return hash.toString(16).padStart(64, "7"); + }, + }; +} + +/** A manifest whose action declares the given computed parameters. */ +function manifestWith(params: Record<string, unknown>) { + return normaliseManifest({ actions: { Open: { params } } }).manifest; +} + +function resolve(params: Record<string, unknown>, supplied: Record<string, unknown> = {}) { + const manifest = manifestWith(params); + + return resolveComputedParams(manifest.actions[0]!, { + contractSources: SOURCES, + hashCovenant: compiler().hashCovenant, + scope: { params: supplied }, + }); +} + +describe("resolveComputedParams", () => { + describe("an action with nothing computed", () => { + test("returns no values and asks the compiler nothing", () => { + const { calls, hashCovenant } = compiler(); + const manifest = manifestWith({ pubkey: { type: "pubkey" } }); + const result = resolveComputedParams(manifest.actions[0]!, { + contractSources: SOURCES, + hashCovenant, + scope: { params: {} }, + }); + + expect(result).toMatchObject({ ok: true }); + expect(result.ok && result.values).toEqual({}); + expect(calls).toHaveLength(0); + }); + }); + + describe("one computed parameter", () => { + test("resolves to the hash of the contract it names", () => { + const result = resolve({ A_COV_HASH: { compute: "tapleaf", simf: "./a.simf" } }); + + expect(result).toMatchObject({ ok: true }); + expect(result.ok && result.values.A_COV_HASH).toMatch(/^[0-9a-f]{64}$/); + }); + + // Convergence is a round that reproduces its own input, so even a value depending on + // nothing costs a confirming round. Reporting the first round instead would say + // "computed once", which for a set of interdependent values does not mean settled. + test("settles on the round that reproduces it", () => { + const result = resolve({ A_COV_HASH: { compute: "tapleaf", simf: "./a.simf" } }); + + expect(result.ok ? result.rounds : 0).toBe(2); + }); + + test("refuses when the contract source was not supplied, naming it", () => { + const manifest = manifestWith({ A: { compute: "tapleaf", simf: "./missing.simf" } }); + const result = resolveComputedParams(manifest.actions[0]!, { + contractSources: {}, + hashCovenant: compiler().hashCovenant, + scope: { params: {} }, + }); + + expect(result.ok ? "" : result.reason).toContain("./missing.simf"); + }); + }); + + // AC-08. A hash cannot be a fixed point of itself, so what converges is a chain that + // only appears circular: auto-populate hands every parameter to every covenant, which + // makes each look dependent on the other, and `depends_on` narrows that to what a + // contract actually consumes. The seed is what makes the first round possible. + describe("covenant hashes that appear to reference each other", () => { + const APPARENT = { + // Auto-populate would hand A's hash to B and B's to A. depends_on says B consumes + // nothing, which turns the apparent cycle into a chain. + A_COV_HASH: { compute: "tapleaf", params: { OTHER: "params.B_COV_HASH" }, simf: "./a.simf" }, + B_COV_HASH: { compute: "tapleaf", depends_on: [], simf: "./b.simf" }, + }; + + test("resolves both to stable values", () => { + const result = resolve(APPARENT); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.values.A_COV_HASH).toMatch(/^[0-9a-f]{64}$/); + expect(result.values.B_COV_HASH).toMatch(/^[0-9a-f]{64}$/); + } + }); + + test("takes more than one round, and stays inside the bound", () => { + const result = resolve(APPARENT); + + expect(result.ok ? result.rounds : 0).toBeGreaterThan(1); + expect(result.ok ? result.rounds : 0).toBeLessThanOrEqual(ITERATION_BOUND); + }); + + test("the values it reports are the ones a further round would reproduce", () => { + const first = resolve(APPARENT); + const second = resolve(APPARENT); + + expect(first.ok ? first.values : {}).toEqual(second.ok ? second.values : {}); + }); + + test("seeds the first round with 32 zero bytes rather than with nothing", () => { + const { calls, hashCovenant } = compiler(); + const manifest = manifestWith(APPARENT); + + resolveComputedParams(manifest.actions[0]!, { + contractSources: SOURCES, + hashCovenant, + scope: { params: {} }, + }); + + expect(calls[0]?.argumentsJson).toContain(COVENANT_HASH_SEED); + }); + + // depends_on is the mechanism, so its absence has to be visible: without it the same + // pair is genuinely circular and the bound is what catches it. + test("without depends_on the same pair is a real cycle and fails", () => { + const result = resolve({ + A_COV_HASH: { + compute: "tapleaf", + params: { OTHER: "params.B_COV_HASH" }, + simf: "./a.simf", + }, + B_COV_HASH: { + compute: "tapleaf", + params: { OTHER: "params.A_COV_HASH" }, + simf: "./b.simf", + }, + }); + + expect(result.ok ? "" : result.reason).toContain("settle"); + }); + }); + + // The bound is what stops an unstable manifest producing an address nobody checked. + describe("when it cannot converge", () => { + test("fails rather than returning the last round's values", () => { + const manifest = manifestWith({ + A: { compute: "tapleaf", params: { OTHER: "params.A" }, simf: "./a.simf" }, + }); + let round = 0; + const result = resolveComputedParams(manifest.actions[0]!, { + contractSources: SOURCES, + // Never settles: a different answer every time it is asked. + hashCovenant: () => { + round += 1; + + return round.toString(16).padStart(64, "0"); + }, + scope: { params: {} }, + }); + + expect(result.ok).toBe(false); + }); + + test("says it was the iteration bound rather than a compilation failure", () => { + const manifest = manifestWith({ + A: { compute: "tapleaf", params: { OTHER: "params.A" }, simf: "./a.simf" }, + }); + let round = 0; + const result = resolveComputedParams(manifest.actions[0]!, { + contractSources: SOURCES, + hashCovenant: () => { + round += 1; + + return round.toString(16).padStart(64, "0"); + }, + scope: { params: {} }, + }); + + expect(result.ok ? "" : result.reason).toContain("settle"); + }); + }); + + describe("what it does not do", () => { + test("refuses a computed kind it does not implement, naming it", () => { + const result = resolve({ A: { compute: "simf_fn", fn: "hash", simf: "./a.simf" } }); + + expect(result.ok ? "" : result.reason).toContain("simf_fn"); + }); + + test("refuses extra leaves, which are the byte-encoding slice's", () => { + const result = resolve({ + A: { compute: "tapleaf", extra_leaves: ["0x00"], simf: "./a.simf" }, + }); + + expect(result.ok ? "" : result.reason).toContain("extra_leaves"); + }); + + test("a supplied parameter is not recomputed", () => { + const { calls, hashCovenant } = compiler(); + const manifest = manifestWith({ pubkey: { type: "pubkey" } }); + + resolveComputedParams(manifest.actions[0]!, { + contractSources: SOURCES, + hashCovenant, + scope: { params: { pubkey: "0x01" } }, + }); + + expect(calls).toHaveLength(0); + }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/computed.ts b/apps/extension/src/core/chains/liquid/domain/manifest/computed.ts new file mode 100644 index 0000000..3628d22 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/computed.ts @@ -0,0 +1,237 @@ +import { resolveCompileParams } from "./compileParams"; +import { asArray, asRecord } from "./json"; +import type { NormalisationNote, NormalisedAction } from "./normalise"; +import type { ReferenceScope } from "./references"; + +/** + * What the first round of iteration stands a covenant hash on. + * + * Thirty-two zero bytes, matching the reference implementation. It is not a plausible hash + * and is not meant to be — it exists so a contract whose parameters name a hash that does + * not exist yet can still be compiled once, which is what makes the second round possible. + */ +export const COVENANT_HASH_SEED = "0".repeat(64); + +/** + * How many rounds a set of mutually referencing hashes gets before the action fails. + * + * A chain of n covenants each naming the next settles in n rounds, so the bound is a limit + * on how deep a protocol may nest rather than on how hard convergence is. Eight is far past + * anything the corpus contains and small enough that an unstable manifest fails quickly. + */ +export const ITERATION_BOUND = 8; + +/** Compiles a contract with its arguments and returns the hash of its scriptPubKey. */ +export type HashCovenant = (input: { argumentsJson: string; source: string }) => string; + +export type ComputedParamsResult = + | { ok: false; reason: string } + | { ok: true; rounds: number; values: Record<string, string> }; + +/** + * Works out the parameters a manifest computes rather than asks for. + * + * A `tapleaf` parameter is a covenant's script hash, and a covenant's compile parameters can + * name another covenant's hash — including, in both directions at once. There is no order in + * which such a pair can be evaluated, so it is not evaluated in an order: every computed + * value starts at a seed, all of them are recomputed together, and the round that reproduces + * its own input is the answer. + * + * A chain that is not circular converges as fast as its depth, so this covers the ordinary + * case without a separate topological pass — the ordering falls out of the iteration. + * + * **Exceeding the bound fails.** The alternative is returning the last round's values, which + * are an address derived from something that never agreed with itself; the wallet would then + * compare that address against the chain and refuse anyway, having spent the work, or worse, + * pay to it. Failing here says which of the two happened. + */ +export function resolveComputedParams( + action: NormalisedAction, + input: { + contractSources: Record<string, string>; + hashCovenant: HashCovenant; + notes?: NormalisationNote[]; + scope: ReferenceScope; + }, +): ComputedParamsResult { + const declared = computedDeclarations(action, input.contractSources, input.scope); + + if (!declared.ok) { + return declared; + } + + if (declared.params.length === 0) { + return { ok: true, rounds: 0, values: {} }; + } + + let values: Record<string, string> = Object.fromEntries( + declared.params.map(({ name }) => [name, COVENANT_HASH_SEED]), + ); + + for (let round = 1; round <= ITERATION_BOUND; round += 1) { + const next: Record<string, string> = {}; + + for (const { declaredTypes, name, node, source } of declared.params) { + const scope: ReferenceScope = { + ...input.scope, + params: { ...input.scope.params, ...values }, + }; + const resolved = resolveCompileParams( + wiringFor(node, scope.params, name), + declaredTypes, + scope, + input.notes, + ); + + if (!resolved.ok) { + return { ok: false, reason: `Computing ${name}: ${resolved.reason}` }; + } + + next[name] = input.hashCovenant({ + argumentsJson: JSON.stringify(resolved.arguments), + source, + }); + } + + if (declared.params.every(({ name }) => next[name] === values[name])) { + return { ok: true, rounds: round, values: next }; + } + + values = next; + } + + return { + ok: false, + reason: + `The covenant hashes this action computes never settle: ${declared.params + .map(({ name }) => name) + .join(", ")} still change after ${ITERATION_BOUND} rounds. ` + + "A transaction built on values that never agreed with themselves would pay to an " + + "address nobody checked.", + }; +} + +type ComputedParam = { + declaredTypes: Record<string, string>; + name: string; + node: Record<string, unknown>; + source: string; +}; + +/** + * Every parameter the action computes, with the contract each one is the hash of. + * + * A parameter the request already supplied is not computed — a value that arrived is a value, + * whatever the manifest says it would otherwise work out. + */ +function computedDeclarations( + action: NormalisedAction, + contractSources: Record<string, string>, + scope: ReferenceScope, +): { ok: false; reason: string } | { ok: true; params: ComputedParam[] } { + const declaredTypes = declaredParamTypes(action.node); + const params: ComputedParam[] = []; + + for (const [name, declared] of Object.entries(asRecord(action.node.params) ?? {})) { + const node = asRecord(declared); + const compute = node?.compute; + + if (!node || compute === undefined || name in scope.params) { + continue; + } + + if (compute !== "tapleaf") { + return { + ok: false, + reason: + `Parameter ${name} is computed by "${String(compute)}", which this runtime does not ` + + "implement. Honouring it means executing a contract while building the " + + "transaction, not merely compiling one.", + }; + } + + if (asArray(node.extra_leaves).length > 0) { + return { + ok: false, + reason: `Parameter ${name} carries extra_leaves, which this runtime does not encode yet.`, + }; + } + + const simf = node.simf; + + if (typeof simf !== "string") { + return { ok: false, reason: `Parameter ${name} names no contract to compute from.` }; + } + + const source = contractSources[simf]; + + if (source === undefined) { + return { ok: false, reason: `The source of ${simf} was not supplied.` }; + } + + params.push({ declaredTypes, name, node, source }); + } + + return { ok: true, params }; +} + +/** + * The compile parameters one computed value is worked out from. + * + * Three modes, and the third is the one that matters. A declared `params` map is used as + * written. Omitting it switches the format into auto-populate: every parameter in scope is + * passed, minus the one being computed, because a contract cannot be compiled with its own + * hash as an input. + * + * `depends_on` narrows auto-populate to a named list, and the reference implementation says + * it exists to break apparent circular dependencies — which is exactly what auto-populate + * creates. Handing every covenant every parameter makes each one look dependent on the + * others whether or not its contract reads them, and a set of hashes that genuinely depend + * on each other has no answer to converge to. So `depends_on: []` is not an empty setting; + * it is a protocol saying this covenant consumes nothing, which is what turns a cycle back + * into a chain. + */ +function wiringFor( + node: Record<string, unknown>, + params: Record<string, unknown>, + exclude: string, +): Record<string, unknown> { + const declared = asRecord(node.params); + + if (declared) { + return declared; + } + + const narrowed = node.depends_on; + const names = Array.isArray(narrowed) + ? narrowed.filter((name): name is string => typeof name === "string") + : Object.keys(params); + + return Object.fromEntries( + names.filter((name) => name !== exclude).map((name) => [name, `params.${name}`]), + ); +} + +/** + * The declared types of an action's parameters, with every computed one defaulted. + * + * A `tapleaf` parameter is a covenant's script hash and is therefore thirty-two bytes by + * construction, whatever the manifest does or does not say — so it needs no declaration, and + * a manifest that omits one is not thereby unbuildable. + */ +function declaredParamTypes(action: Record<string, unknown>): Record<string, string> { + const types: Record<string, string> = {}; + + for (const [name, declared] of Object.entries(asRecord(action.params) ?? {})) { + const record = asRecord(declared); + const type = record?.type; + + if (typeof type === "string") { + types[name] = type; + } else if (record?.compute === "tapleaf") { + types[name] = "bytes32"; + } + } + + return types; +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.test.ts new file mode 100644 index 0000000..dd5399b --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { covenantHashFrom } from "./covenantHash"; + +// A covenant script hash is SHA256 of the scriptPubKey the contract compiles to — the value +// the Simplicity `input_script_hash` jet returns, and what a manifest's *_COV_HASH fields +// hold. What is asserted here is that this computes that and nothing else; whether the +// scriptPubKey itself is right is the compiler's business and is covered against the real +// module elsewhere. + +describe("covenantHashFrom", () => { + test("hashes the scriptPubKey the contract compiles to", () => { + const hash = covenantHashFrom(() => "00")({ argumentsJson: "{}", source: "fn main() { }" }); + + // SHA256 of the single byte 0x00. + expect(hash).toBe("6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d"); + }); + + test("a different scriptPubKey gives a different hash", () => { + const one = covenantHashFrom(() => "00")({ argumentsJson: "{}", source: "a" }); + const other = covenantHashFrom(() => "01")({ argumentsJson: "{}", source: "a" }); + + expect(one).not.toBe(other); + }); + + test("hashes the bytes rather than the text of the hex", () => { + const lower = covenantHashFrom(() => "abcd")({ argumentsJson: "{}", source: "a" }); + const upper = covenantHashFrom(() => "ABCD")({ argumentsJson: "{}", source: "a" }); + + expect(lower).toBe(upper); + }); + + test("passes the source and arguments through to the compiler unchanged", () => { + const seen: { argumentsJson: string; source: string }[] = []; + + covenantHashFrom((input) => { + seen.push(input); + + return "00"; + })({ argumentsJson: '{"A":1}', source: "fn main() { }" }); + + expect(seen).toEqual([{ argumentsJson: '{"A":1}', source: "fn main() { }" }]); + }); + + test("refuses a scriptPubKey that is not hex rather than hashing the text", () => { + expect(() => covenantHashFrom(() => "zz")({ argumentsJson: "{}", source: "a" })).toThrow(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.ts new file mode 100644 index 0000000..16b724d --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.ts @@ -0,0 +1,20 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; + +import type { HashCovenant } from "./computed"; + +/** Compiles a contract with its arguments and returns the scriptPubKey it locks to, as hex. */ +export type CompileScriptPubKey = (input: { argumentsJson: string; source: string }) => string; + +/** + * Turns a compiler into the covenant-hash function the computed parameters need. + * + * A covenant's script hash is `SHA256(scriptPubKey)` — the value Simplicity's + * `input_script_hash` jet returns, and what a manifest's `*_COV_HASH` fields hold. It is + * therefore a hash of the *bytes*, and the compiler hands back hex; decoding first rather + * than hashing the text is the difference between the value a contract will check against + * and a plausible-looking wrong one. + */ +export function covenantHashFrom(compile: CompileScriptPubKey): HashCovenant { + return (input) => bytesToHex(sha256(hexToBytes(compile(input)))); +} From c4702e2e1820fe36366982e315f0b6fff4d6bcd1 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 15:52:36 +0300 Subject: [PATCH 039/124] fix(liquid): ask the signer for the signature a covenant demands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A covenant that authenticates whoever spends it asserts a signature over the transaction being built. Nothing was asking for one: the builder was never given the witness, and the binding told the signer no signature was required. So the spend half of every covenant protocol failed at signing with "missing witness", and no test caught it because every test that reaches signing substitutes the module. Which witness needs signing is in the manifest already — a Signature witness sourced from the wallet — so it is read from there and carried to the builder. The regression is asserted against the real module in both directions. Found by measuring what a transaction weighs, which is also recorded here: 257 vbytes for one wallet input and one output, +69 per further wallet input, +67 per further output, +87 for a p2pk covenant input. --- .../adapters/smplx/loadSmplxWasm.test.ts | 97 ++++++++++++++++ .../processConfidentialTransaction/index.ts | 7 ++ .../liquid/domain/manifest/review.test.ts | 104 ++++++++++++++++++ .../chains/liquid/domain/manifest/review.ts | 91 ++++++++++++--- .../chains/liquid/domain/manifest/sites.ts | 37 ++++++- smplx | 2 +- 6 files changed, 322 insertions(+), 16 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 6910d92..19d40b3 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -392,3 +392,100 @@ describe("covenant inputs and the dry-run", () => { builder.free(); }); }); + +// Spending a covenant that authenticates whoever spends it needs a signature over the +// transaction being built, which only the signer can make. Naming the witness is how it is +// asked for; without that name the spend fails at signing with "missing witness", which is +// what this wallet did until it was measured. +describe("signing a covenant that authenticates its spender", () => { + const TXID = "3".repeat(64); + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + const P2PK_SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + const RATE = 1000; + + function txOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const value = sats.toString(16).padStart(16, "0"); + const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${len}${scriptHex}`; + } + + /** A transaction of the given shape, signed, returning the fee it was charged. */ + function feeFor(walletInputs: number, covenantInputs: number, outputs: number, name?: string) { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + const args = JSON.stringify({ + PUB_KEY: { type: "Pubkey", value: `0x${signer.schnorrPublicKey()}` }, + }); + const covenantScript = new bindings.Contract(P2PK_SOURCE, args).scriptPubKeyHex( + "liquid-testnet", + ); + + try { + for (let i = 0; i < covenantInputs; i += 1) { + builder.addCovenantInput( + TXID, + i, + txOut(200_000n, covenantScript), + P2PK_SOURCE, + args, + undefined, + name, + ); + } + + for (let i = 0; i < walletInputs; i += 1) { + builder.addWalletInput(TXID, 50 + i, txOut(200_000n, signer.scriptPubKeyHex())); + } + + for (let i = 0; i < outputs; i += 1) { + builder.addOutput(signer.scriptPubKeyHex(), 10_000n, POLICY_ASSET); + } + + const signed = signer.finalizeTransaction(builder, RATE, signer.scriptPubKeyHex()); + const fee = signed.feeSats; + + signed.free(); + + return fee; + } finally { + builder.free(); + signer.free(); + } + } + + test("signs the covenant when the witness needing a signature is named", () => { + expect(feeFor(1, 1, 1, "SIGNATURE") > 0n).toBe(true); + }); + + // The regression: this is exactly what the wallet did before the witness was named. + test("fails to satisfy the program when it is not", () => { + expect(() => feeFor(1, 1, 1)).toThrow(/missing witness for SIGNATURE/); + }); + + // At a rate of 1000 sat/kvb the fee in satoshis is the vsize, so these are sizes. They + // are what a fee estimate has to be built from, and a toolchain change that moves them + // moves every fee with them — which is why they are asserted rather than noted. + describe("what a transaction of each shape weighs", () => { + test("one wallet input and one output, plus the change and fee smplx adds", () => { + expect(feeFor(1, 0, 1)).toBe(257n); + }); + + test("a further wallet input costs 69", () => { + expect(feeFor(2, 0, 1) - feeFor(1, 0, 1)).toBe(69n); + }); + + test("a further output costs 67", () => { + expect(feeFor(1, 0, 2) - feeFor(1, 0, 1)).toBe(67n); + }); + + // A covenant input's witness is the Simplicity witness, so its size belongs to the + // program rather than to the shape. This is p2pk's, the smallest real one there is. + test("a p2pk covenant input costs 87, and a second 86", () => { + expect(feeFor(1, 1, 1, "SIGNATURE") - feeFor(1, 0, 1)).toBe(87n); + expect(feeFor(1, 2, 1, "SIGNATURE") - feeFor(1, 1, 1, "SIGNATURE")).toBe(86n); + }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 90f2a3c..8adf913 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -149,6 +149,11 @@ export const createProcessLiquidConfidentialTransaction = ( covenant.txOutHex, covenant.source, covenant.argumentsJson, + // No witness values: a covenant that authenticates its spender needs a + // signature over this transaction, which only the signer can make, and + // naming it is what asks for one. + undefined, + covenant.signatureWitness, ); } @@ -207,6 +212,8 @@ export const createProcessLiquidConfidentialTransaction = ( const result = await reviewManifestAction(params, { compile: ({ argumentsJson, network: target, source }) => new smplx.Contract(source, argumentsJson).covenantAddress(target), + scriptPubKeyOf: ({ argumentsJson, source }) => + new smplx.Contract(source, argumentsJson).scriptPubKeyHex(network), fundingUtxos: context.walletBackend.getUtxos(account, account.rawPolicyAssetId), network, readFeeRate: dependencies.readFeeRate(context.chain), diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts index 2572a4a..1bb5c89 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import groupedManifest from "./__fixtures__/p2pk-grouped.manifest.json"; import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import { estimateFeeSats } from "./fee"; import { isRefusal, reviewManifestAction } from "./review"; import type { ParsedLiquidProcessCtParams } from "./types"; @@ -38,6 +39,9 @@ const deps = { fundingUtxos, network: "liquid", readFeeRate, + // The p2pk manifest computes nothing, so this is never reached on these cases; a + // covenant hash the manifest works out for itself is covered where it is built. + scriptPubKeyOf: () => "5120aabb", walletScriptPubKeyHex: WALLET_SCRIPT, }; const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; @@ -306,3 +310,103 @@ describe("reviewManifestAction reads through the runtime core", () => { } }); }); + +// AC-13: the fee and the fee rate are the wallet's alone. +describe("who decides the fee", () => { + test("the rate comes from the chain, not from the request", async () => { + const result = await reviewManifestAction(request(), { + ...deps, + readFeeRate: async () => 1234, + readTxOut: readTxOut("unused"), + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.feeRateSatsPerKvb).toBe(1234); + } + }); + + test("refuses rather than defaulting when no rate can be established", async () => { + const result = await reviewManifestAction(request(), { + ...deps, + readFeeRate: async () => { + throw new Error("no estimate"); + }, + readTxOut: readTxOut("unused"), + }); + + expect(isRefusal(result)).toBe(true); + expect(isRefusal(result) ? result.reason : "").toContain("fee rate"); + }); +}); + +// AC-09: an amount that is a function of the fee is worked out against the fee the wallet +// established, and the transaction it produces is the one the person is shown. +describe("an amount that depends on the fee", () => { + // A one-input, one-output spend of the covenant, paying out what it holds less the fee. + const feeAware = { + actions: { + Sweep: { + inputs: [ + { + id: "cov_in", + utxo_source: { compile_params: { PUB_KEY: "params.pubkey" }, utxo_type: "p2pk_output" }, + witnesses: { + SIGNATURE: { source: { key: "params.pubkey", type: "wallet" }, type: "Signature" }, + }, + }, + ], + outputs: [{ amount_sat: "cov_in.amount_sat - fee", destination: "wallet", id: "swept" }], + params: { pubkey: { type: "pubkey" } }, + }, + }, + protocol: "p2pk-simplicity", + utxo_types: { + p2pk_output: { script: { source: SOURCE_PATH, type: "simplicity" } }, + }, + }; + + const sweep = () => + reviewManifestAction( + request({ + action: "Sweep", + manifest: feeAware, + params: { pubkey: PUBKEY }, + state: oneCovenantUtxo as Record<string, unknown>, + }), + { ...deps, readTxOut: readTxOut(DERIVED, "42000") }, + ); + + test("pays out what the covenant holds, less what the wallet worked the fee out to be", async () => { + const result = await sweep(); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.outputs[0]?.sats).toBe(42_000n - result.estimatedFeeSats); + } + }); + + test("the fee it used is the one its own shape costs at the rate it read", async () => { + const result = await sweep(); + + if (!isRefusal(result)) { + // One covenant input, one output, and the wallet input the estimate assumes. + expect(result.estimatedFeeSats).toBe( + estimateFeeSats( + { covenantInputs: 1, outputs: 1, walletInputs: 1 }, + result.feeRateSatsPerKvb, + ), + ); + } + }); + + test("carries the witness the covenant needs a signature for", async () => { + const result = await sweep(); + + if (!isRefusal(result)) { + expect(result.covenantInputs[0]?.signatureWitness).toBe("SIGNATURE"); + } + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts index 42be55c..2d32c90 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -1,6 +1,9 @@ import { encodeExplicitTxOut, type ReadFeeRate, type ReadTxOut } from "./chainRead"; import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; +import { resolveComputedParams } from "./computed"; import { type CompileCovenant, covenantMatchesChain, deriveCovenantAddress } from "./covenant"; +import { type CompileScriptPubKey, covenantHashFrom } from "./covenantHash"; +import { estimateFeeSats } from "./fee"; import { asArray, asRecord } from "./json"; import { findAction, @@ -37,6 +40,15 @@ export type CovenantFinding = { */ export type ReviewedCovenantInput = { argumentsJson: string; + /** + * The witness the signer must fill with a signature over this transaction. + * + * Carried through from the manifest's own declaration because the alternative is not + * signing it: a covenant whose program asserts a signature cannot be satisfied by anything + * the request supplies, and leaving this unset makes the spend fail at signing rather than + * anywhere a person could act on. + */ + signatureWitness?: string; source: string; txOutHex: string; txid: string; @@ -63,7 +75,15 @@ export type ManifestReview = { covenants: CovenantFinding[]; /** The covenant outputs this action spends, ready to be added as inputs. */ covenantInputs: ReviewedCovenantInput[]; - /** What the wallet will pay, established from the chain rather than from the request. */ + /** + * What the wallet worked out this will cost, from the shape of the transaction it built. + * + * An estimate rather than the charged figure, and the two differ: the fee that is charged + * comes from the weight of the signed transaction, which does not exist until after the + * person agrees. The difference returns to them as change. + */ + estimatedFeeSats: bigint; + /** What the wallet will pay per kilo-vbyte, established from the chain rather than from the request. */ feeRateSatsPerKvb: number; /** * Constructs the manifest carries that this runtime did not act on and did not need to. @@ -110,6 +130,8 @@ export async function reviewManifestAction( network: string; readFeeRate: ReadFeeRate; readTxOut: ReadTxOut; + /** Compiles a contract to the scriptPubKey it locks to, for the hashes a manifest computes. */ + scriptPubKeyOf: CompileScriptPubKey; walletScriptPubKeyHex: string; }, ): Promise<ReviewManifestActionResult> { @@ -140,10 +162,24 @@ export async function reviewManifestAction( /** What each covenant input actually holds, read from the chain rather than told. */ const inputs: Record<string, Record<string, unknown>> = {}; + // The parameters a manifest works out for itself come first: a covenant compiled with + // another covenant's hash needs that hash before its own address can be derived, and a + // hash cannot depend on what the chain reports at an address that does not exist yet. + const computed = resolveComputedParams(action, { + contractSources: request.contractSources, + hashCovenant: covenantHashFrom(input.scriptPubKeyOf), + notes, + scope: { instance: deployment.instance.fields, params: request.params }, + }); + + if (!computed.ok) { + return { reason: computed.reason, refused: true }; + } + const scope: ReferenceScope = { inputs, instance: deployment.instance.fields, - params: request.params, + params: { ...request.params, ...computed.values }, }; for (const site of covenantSites(action)) { @@ -216,6 +252,7 @@ export async function reviewManifestAction( covenantInputs.push({ argumentsJson: derived.derivation.argumentsJson, + ...(site.signatureWitness === undefined ? {} : { signatureWitness: site.signatureWitness }), source: derived.derivation.source, txOutHex, txid: outpoint.txid, @@ -230,7 +267,43 @@ export async function reviewManifestAction( }); } - const plan = planAction(action, scope, notes); + let feeRateSatsPerKvb: number; + + try { + feeRateSatsPerKvb = await input.readFeeRate(FEE_TARGET_BLOCKS); + } catch (error) { + return { + reason: `The wallet could not establish a fee rate, so it will not build this: ${String(error)}`, + refused: true, + }; + } + + // The fee is planned for twice. An amount can be a function of the fee — "pay out what + // this input holds, less what the network takes" — and the fee depends on the shape of + // the transaction those amounts appear in, so a draft is planned against a fee of zero + // purely to learn the shape, and the real pass runs against the figure that shape costs. + // + // One pass is enough because an amount does not change what a transaction weighs: in + // Elements a value occupies a fixed size whatever its magnitude. Without that property + // this would not converge. + const draft = planAction(action, { ...scope, fee: 0n }, notes); + + if (!draft.ok) { + return { reason: draft.reason, refused: true }; + } + + const estimatedFee = estimateFeeSats( + { + covenantInputs: covenantInputs.length, + outputs: draft.plan.outputs.length, + // The wallet has not chosen its inputs yet, and one is the common case; a + // selection that takes more is priced below, before anything is committed to. + walletInputs: 1, + }, + feeRateSatsPerKvb, + ); + + const plan = planAction(action, { ...scope, fee: estimatedFee }, notes); if (!plan.ok) { return { reason: plan.reason, refused: true }; @@ -261,17 +334,6 @@ export async function reviewManifestAction( outputs.push({ id: planned.id, sats: planned.sats, scriptPubKeyHex }); } - let feeRateSatsPerKvb: number; - - try { - feeRateSatsPerKvb = await input.readFeeRate(FEE_TARGET_BLOCKS); - } catch (error) { - return { - reason: `The wallet could not establish a fee rate, so it will not build this: ${String(error)}`, - refused: true, - }; - } - const selection: CoinSelection = selectCoins( input.fundingUtxos, plan.plan.fundingSats, @@ -286,6 +348,7 @@ export async function reviewManifestAction( action: request.action, covenantInputs, covenants, + estimatedFeeSats: estimatedFee, feeRateSatsPerKvb, ignoredConstructs: ignored(inspectConstructs(manifest)), normalisation: notes, diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/sites.ts b/apps/extension/src/core/chains/liquid/domain/manifest/sites.ts index b1e074f..0ad7427 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/sites.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/sites.ts @@ -13,6 +13,15 @@ export type CovenantSite = { /** The manifest's id for this input or output, which its amounts refer to it by. */ id: string; role: "created" | "spent"; + /** + * The witness this covenant's program needs a signature for, when it has one. + * + * A covenant that authenticates whoever spends it declares a `Signature` witness sourced + * from a wallet key, and the signer is the only thing that can fill it — nothing in the + * request could, because the signature is over a transaction that does not exist yet. + * Absent for a covenant that needs no signature, and for one being created. + */ + signatureWitness?: string; utxoType: string; /** The compile parameters wired in at this site, unresolved. */ wiring: Record<string, unknown>; @@ -25,7 +34,14 @@ export function covenantSites(action: NormalisedAction): CovenantSite[] { const site = covenantReference(asRecord(entry)?.utxo_source); if (site) { - sites.push({ ...site, id: identifierOf(entry), role: "spent" }); + const signatureWitness = walletSignatureWitness(asRecord(entry)?.witnesses); + + sites.push({ + ...site, + id: identifierOf(entry), + role: "spent", + ...(signatureWitness === undefined ? {} : { signatureWitness }), + }); } } @@ -63,3 +79,22 @@ function covenantReference( return { utxoType, wiring: asRecord(record?.compile_params) ?? {} }; } + +/** + * The witness a wallet key must sign, from an input's witness declarations. + * + * Only a `Signature` witness sourced from the wallet qualifies. One with a literal value is + * already supplied, and one sourced from a formula is worked out rather than signed — asking + * the signer for either would produce a signature nothing checks. + */ +function walletSignatureWitness(declared: unknown): string | undefined { + for (const [name, entry] of Object.entries(asRecord(declared) ?? {})) { + const witness = asRecord(entry); + + if (witness?.type === "Signature" && asRecord(witness.source)?.type === "wallet") { + return name; + } + } + + return undefined; +} diff --git a/smplx b/smplx index ca7fef3..f42c509 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit ca7fef3d969dcdd8cf9e4c5de390dffe9c3d8ced +Subproject commit f42c5092875bdc87b8dcf341bb612325202211d3 From a529ac59d426174d50281ba6c1b536efc754c54a Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 15:52:38 +0300 Subject: [PATCH 040/124] feat(liquid): price the transaction before asking anyone to approve it An amount can be a function of the fee, and the fee depends on the shape the amounts appear in, so a draft is planned against a fee of zero purely to learn the shape and the real pass runs against what that shape costs. One pass is enough because an amount does not change what a transaction weighs. The estimate is not the fee that gets charged and cannot be: smplx does not estimate, it signs and weighs the result, and signing before approval is what the confirmation exists to prevent. The difference returns to the person as change, and the model over-states by a vbyte rather than under-stating so it goes that way. A covenant asserting the exact relationship refuses at signing instead. Every number in the model was measured against the real module and is a standing test, so a toolchain change that moves them fails the suite rather than quietly moving every fee. --- .../chains/liquid/domain/manifest/fee.test.ts | 53 ++++++++++++++ .../core/chains/liquid/domain/manifest/fee.ts | 71 +++++++++++++++++++ .../liquid/domain/manifest/validation.test.ts | 35 +++++++++ 3 files changed, 159 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/fee.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/fee.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/validation.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/fee.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/fee.test.ts new file mode 100644 index 0000000..8470b97 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/fee.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; + +import { estimateFeeSats, estimateVsize } from "./fee"; + +// The shapes below reproduce the measurements taken against the real signing module in +// loadSmplxWasm.test.ts. If those move, these fail with them — which is the point: a fee +// model that drifts from what the module charges is worse than no model, because nothing +// downstream would notice. + +describe("estimateVsize", () => { + test("one wallet input and one output", () => { + expect(estimateVsize({ covenantInputs: 0, outputs: 1, walletInputs: 1 })).toBe(257n); + }); + + test("two wallet inputs and one output", () => { + expect(estimateVsize({ covenantInputs: 0, outputs: 1, walletInputs: 2 })).toBe(326n); + }); + + test("one wallet input and two outputs", () => { + expect(estimateVsize({ covenantInputs: 0, outputs: 2, walletInputs: 1 })).toBe(324n); + }); + + test("one wallet input, one covenant input and one output", () => { + expect(estimateVsize({ covenantInputs: 1, outputs: 1, walletInputs: 1 })).toBe(344n); + }); + + // The module charged 430 for this shape and the model says 431. It over-states rather + // than under-states, which is the direction that returns the difference to the person as + // change instead of taking it as a larger fee. + test("a second covenant input is estimated one vbyte high, deliberately", () => { + expect(estimateVsize({ covenantInputs: 2, outputs: 1, walletInputs: 1 })).toBe(431n); + }); +}); + +describe("estimateFeeSats", () => { + const shape = { covenantInputs: 0, outputs: 1, walletInputs: 1 }; + + test("at a rate of 1000 the fee is the vsize", () => { + expect(estimateFeeSats(shape, 1000)).toBe(257n); + }); + + test("at a rate of 100 it is a tenth, rounded up", () => { + expect(estimateFeeSats(shape, 100)).toBe(26n); + }); + + test("rounds up rather than down, so the transaction is never short", () => { + expect(estimateFeeSats({ covenantInputs: 0, outputs: 1, walletInputs: 1 }, 1)).toBe(1n); + }); + + test("a fractional rate is taken at its ceiling", () => { + expect(estimateFeeSats(shape, 100.1)).toBe(estimateFeeSats(shape, 101)); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/fee.ts b/apps/extension/src/core/chains/liquid/domain/manifest/fee.ts new file mode 100644 index 0000000..0024380 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/fee.ts @@ -0,0 +1,71 @@ +/** + * What a transaction of a given shape weighs, and what it therefore costs. + * + * Every number here was measured against the real signing module rather than modelled from + * the Elements serialisation: a transaction of each shape was built, signed and the fee it + * was charged at 1000 sat/kvb read back, which at that rate is the vsize. The measurements + * are standing tests, so a toolchain change that moves them fails the suite instead of + * quietly moving every fee. + * + * **The runtime's figure cannot equal the one that is charged.** smplx does not estimate a + * fee; it signs the transaction and weighs the result. Signing before the person has agreed + * is what the confirmation exists to prevent, so before approval there is a model and after + * it there is a measurement, and they differ. What makes that safe is where the difference + * goes: smplx sets change to whatever is left after its own fee, so an over-estimate returns + * to the person as change and an under-estimate is absorbed by a larger fee. The transaction + * balances either way. + * + * The one case that is not absorbed is an output whose amount is a function of the fee. It + * is computed against this figure and the chain charges the other, and the difference lands + * in change. A covenant that asserts the exact relationship rejects that — and rejects it at + * signing, where the program is executed against the witness actually produced, so it + * surfaces as a refusal rather than as a wrong payment. + */ + +/** The fixed part: the change and fee outputs smplx adds, and the transaction's own header. */ +const BASE_VSIZE = 121n; + +/** A wallet input, spending a P2WPKH output the wallet owns. */ +const PER_WALLET_INPUT = 69n; + +/** An output the action declares. */ +const PER_OUTPUT = 67n; + +/** + * A covenant input, measured on `p2pk` — one signature check and nothing else. + * + * This is the number that belongs to the program rather than to the shape: a covenant + * input's witness is the Simplicity witness, and a larger contract carries a larger one. + * p2pk is the smallest real covenant there is, so this under-states every other one, which + * is why an action that references the fee is worth treating as approximate rather than + * exact. + */ +const PER_COVENANT_INPUT = 87n; + +export type TransactionShape = { + covenantInputs: number; + outputs: number; + walletInputs: number; +}; + +/** The virtual size a transaction of this shape signs to. */ +export function estimateVsize(shape: TransactionShape): bigint { + return ( + BASE_VSIZE + + PER_WALLET_INPUT * BigInt(shape.walletInputs) + + PER_OUTPUT * BigInt(shape.outputs) + + PER_COVENANT_INPUT * BigInt(shape.covenantInputs) + ); +} + +/** + * The fee a transaction of this shape costs at this rate. + * + * Rounded up, matching `calculate_fee` in the SDK, which is `ceil(vsize * rate / 1000)`. + */ +export function estimateFeeSats(shape: TransactionShape, rateSatsPerKvb: number): bigint { + const rate = BigInt(Math.ceil(rateSatsPerKvb)); + const scaled = estimateVsize(shape) * rate; + + return scaled / 1000n + (scaled % 1000n === 0n ? 0n : 1n); +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/validation.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/validation.test.ts new file mode 100644 index 0000000..3720203 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/validation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; + +import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import { parseLiquidProcessCtParams } from "./validation"; + +// AC-13's other half: a site cannot set the fee. The request contract has no place to put +// one, and a request that invents one is refused rather than quietly stripped — a site that +// believes it set the fee and was ignored is a site that will keep believing it. + +const base = { + action: "Pay", + contractSources: { "./p2pk.simf": "fn main() { }" }, + manifest: p2pkManifest, + params: { amount_sat: 1000 }, +}; + +describe("parseLiquidProcessCtParams", () => { + test("accepts the six-part request", () => { + expect(parseLiquidProcessCtParams(base).action).toBe("Pay"); + }); + + test("defaults broadcast to off, so nothing reaches the network unasked", () => { + expect(parseLiquidProcessCtParams(base).broadcast).toBe(false); + }); + + for (const supplied of ["fee", "feeSats", "feeRate", "feeRateSatsPerKvb"]) { + test(`refuses a request carrying ${supplied}`, () => { + expect(() => parseLiquidProcessCtParams({ ...base, [supplied]: 500 })).toThrow(); + }); + } + + test("refuses a request with no manifest", () => { + expect(() => parseLiquidProcessCtParams({ ...base, manifest: undefined })).toThrow(); + }); +}); From f3bfc61e86f1cd1100dbdc32f0bae7d9e6438dc7 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 15:56:38 +0300 Subject: [PATCH 041/124] feat(liquid): declare a value's bytes rather than inferring them A covenant's address comes from the exact bytes of its extra taproot leaves, so an encoding read wrong does not produce an error. It produces a well-formed address for a contract nobody deployed, and the wallet then refuses a legitimate action for a reason nothing on screen explains. The format has two encoding vocabularies and they are not the same one. Extra leaves have seven types, an endianness and a padding rule; an output's object-form data has three types and neither. Kept apart, so a part carrying endian where the format has no such key is refused rather than encoded as if it had said nothing. One ordering is ours and no document states it: encode to the type's width, apply the endianness, then pad. Padding first would turn a right-aligned pad into a left-aligned one. --- .../liquid/domain/manifest/encode.test.ts | 191 +++++++++++++ .../chains/liquid/domain/manifest/encode.ts | 264 ++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/encode.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/encode.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/encode.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/encode.test.ts new file mode 100644 index 0000000..94134bf --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/encode.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeDataParts, encodeLeafItem } from "./encode"; + +// The vocabulary comes from the cross-source inventory, which read it out of the reference +// implementation's `encode_leaf_bytes`: type is one of u8/u16/u32/u64 or +// bytes32/bytes/pubkey; endian defaults to little and "be" reverses; pad_to errors rather +// than truncating; align defaults to right. Anything else is rejected outright, because a +// value encoded wrong produces a well-formed address for the wrong contract. + +function hex(item: unknown, stateVars: Record<string, unknown> = {}): string { + const result = encodeLeafItem(item, stateVars); + + return result.ok ? result.hex : result.reason; +} + +describe("encodeLeafItem", () => { + describe("a hex literal", () => { + test("passes through", () => { + expect(hex("0x0102")).toBe("0102"); + }); + + test("is accepted without the prefix", () => { + expect(hex("0102")).toBe("0102"); + }); + + test("is refused when it is not hex", () => { + expect(hex("0xzz")).toContain("not"); + }); + + test("is refused when it has an odd number of digits", () => { + expect(hex("0x010")).toContain("whole"); + }); + }); + + describe("integer widths", () => { + test("u8 is one byte", () => { + expect(hex({ type: "u8", value: 1 })).toBe("01"); + }); + + test("u16 is two, little-endian by default", () => { + expect(hex({ type: "u16", value: 1 })).toBe("0100"); + }); + + test("u32 is four", () => { + expect(hex({ type: "u32", value: 1 })).toBe("01000000"); + }); + + test("u64 is eight", () => { + expect(hex({ type: "u64", value: 1 })).toBe("0100000000000000"); + }); + + test("big-endian reverses it", () => { + expect(hex({ endian: "be", type: "u32", value: 1 })).toBe("00000001"); + }); + + test("anything other than be is little-endian, as the default is", () => { + expect(hex({ endian: "le", type: "u32", value: 1 })).toBe("01000000"); + }); + + test("a decimal string is read as the number it spells", () => { + expect(hex({ type: "u16", value: "258" })).toBe("0201"); + }); + + test("a value too large for its width is refused rather than truncated", () => { + expect(hex({ type: "u8", value: 256 })).toContain("u8"); + }); + + test("a negative value is refused", () => { + expect(hex({ type: "u8", value: -1 })).toContain("negative"); + }); + + test("a value beyond a double's range survives as a decimal string", () => { + expect(hex({ endian: "be", type: "u64", value: "9007199254740993" })).toBe( + "0020000000000001", + ); + }); + }); + + describe("byte types", () => { + test("bytes takes hex as it is", () => { + expect(hex({ type: "bytes", value: "0xdeadbeef" })).toBe("deadbeef"); + }); + + test("bytes32 requires thirty-two", () => { + expect(hex({ type: "bytes32", value: `0x${"11".repeat(32)}` })).toBe("11".repeat(32)); + expect(hex({ type: "bytes32", value: "0x1122" })).toContain("bytes32"); + }); + + test("pubkey requires thirty-two", () => { + expect(hex({ type: "pubkey", value: `0x${"22".repeat(32)}` })).toBe("22".repeat(32)); + expect(hex({ type: "pubkey", value: "0x2233" })).toContain("pubkey"); + }); + + test("big-endian reverses bytes too", () => { + expect(hex({ endian: "be", type: "bytes", value: "0x0102" })).toBe("0201"); + }); + }); + + describe("padding", () => { + test("pads in front by default, which is what right alignment means", () => { + expect(hex({ pad_to: 4, type: "u8", value: 1 })).toBe("00000001"); + }); + + test("left alignment puts the value first and the zeros after", () => { + expect(hex({ align: "left", pad_to: 4, type: "u8", value: 1 })).toBe("01000000"); + }); + + test("anything other than left is right, as the default is", () => { + expect(hex({ align: "centre", pad_to: 2, type: "u8", value: 1 })).toBe("0001"); + }); + + test("a value longer than the target is an error, never a truncation", () => { + expect(hex({ pad_to: 1, type: "u32", value: 1 })).toContain("longer"); + }); + + test("a target equal to the length changes nothing", () => { + expect(hex({ pad_to: 4, type: "u32", value: 1 })).toBe("01000000"); + }); + }); + + describe("a state variable", () => { + test("resolves to its default value as one byte", () => { + expect(hex({ state_var: "counter" }, { counter: { default_value: 7 } })).toBe("07"); + }); + + test("is refused when the utxo type declares no such variable", () => { + expect(hex({ state_var: "missing" }, {})).toContain("missing"); + }); + + test("is refused when its default does not fit in a byte", () => { + expect(hex({ state_var: "big" }, { big: { default_value: 256 } })).toContain("u8"); + }); + }); + + describe("what it refuses outright", () => { + test("a type nobody has mapped", () => { + expect(hex({ type: "u128", value: 1 })).toContain("u128"); + }); + + test("an item that is neither a literal, a typed value nor a state variable", () => { + expect(hex(42)).toContain("not"); + }); + + test("a typed item with no value", () => { + expect(hex({ type: "u8" })).toContain("value"); + }); + }); +}); + +// The second vocabulary, on an output's `data`. Distinct from the first: no endian, no +// padding, and only three types. Written separately rather than folded into the first, +// because a shared encoder would silently accept `endian` here where the format has none. +describe("encodeDataParts", () => { + function parts(value: unknown): string { + const result = encodeDataParts(value); + + return result.ok ? result.hex : result.reason; + } + + test("concatenates its parts in order", () => { + expect( + parts({ + parts: [ + { type: "u8", value: 1 }, + { type: "bytes", value: "0xabcd" }, + ], + }), + ).toBe("01abcd"); + }); + + test("u64 is eight bytes, big-endian, as this vocabulary has no endian to choose", () => { + expect(parts({ parts: [{ type: "u64", value: 1 }] })).toBe("0000000000000001"); + }); + + test("refuses a type this vocabulary does not have", () => { + expect(parts({ parts: [{ type: "u32", value: 1 }] })).toContain("u32"); + }); + + test("refuses the first vocabulary's keys, which mean nothing here", () => { + expect(parts({ parts: [{ endian: "be", type: "u8", value: 1 }] })).toContain("endian"); + }); + + test("refuses data that is not a parts list", () => { + expect(parts({ parts: "0x00" })).toContain("parts"); + }); + + test("an empty parts list encodes to nothing", () => { + expect(parts({ parts: [] })).toBe(""); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/encode.ts b/apps/extension/src/core/chains/liquid/domain/manifest/encode.ts new file mode 100644 index 0000000..51bfda1 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/encode.ts @@ -0,0 +1,264 @@ +import { asArray, asRecord } from "./json"; + +export type EncodedBytes = { hex: string; ok: true } | { ok: false; reason: string }; + +/** + * The format's two byte-encoding vocabularies, kept apart. + * + * A covenant's address is derived from the exact bytes of its extra taproot leaves, so an + * encoding read wrong does not produce an error — it produces a well-formed address for a + * contract nobody deployed, and the wallet then refuses a legitimate action for a reason + * nothing on screen explains. Every encoding is therefore declared and refused when it is + * not recognised, and nothing is inferred from the shape of a value. + * + * The two vocabularies are genuinely different and are implemented separately rather than + * unified. `extra_leaves` has seven types, an endianness and a padding rule; an output's + * object-form `data` has three types and neither. Folding them together would silently + * accept `endian` in a position where the format has no such key, which is the shape of a + * mistake that changes bytes without changing anything visible. + */ + +/** The integer widths `extra_leaves` accepts, in bytes. */ +const LEAF_WIDTHS: Record<string, number> = { u16: 2, u32: 4, u64: 8, u8: 1 }; + +/** The byte types `extra_leaves` accepts, with the exact length each requires. */ +const LEAF_BYTE_LENGTHS: Record<string, number | undefined> = { + bytes: undefined, + bytes32: 32, + pubkey: 32, +}; + +/** + * Encodes one item of a utxo type's `extra_leaves`. + * + * Three shapes: a hex literal, a typed value, and a reference to one of the utxo type's own + * state variables — which resolves to that variable's default as a single byte. + * + * **Order of operations, stated because it decides addresses.** The value is encoded to its + * type's natural width, then the endianness is applied, then padding extends it. Padding + * before reversing would turn a right-aligned pad into a left-aligned one, so the order is + * not arbitrary; it is also not something any document states, and is recorded as an + * uncertainty rather than presented as established. + */ +export function encodeLeafItem( + item: unknown, + stateVars: Record<string, unknown> = {}, +): EncodedBytes { + if (typeof item === "string") { + return fromHex(item); + } + + const record = asRecord(item); + + if (!record) { + return { + ok: false, + reason: "An extra leaf is not a hex literal, a typed value or a state variable.", + }; + } + + if (typeof record.state_var === "string") { + return fromStateVar(record.state_var, stateVars); + } + + const type = record.type; + + if (typeof type !== "string") { + return { ok: false, reason: "An extra leaf declares no type, and none is inferred." }; + } + + if (!("value" in record)) { + return { ok: false, reason: `The ${type} extra leaf carries no value.` }; + } + + const encoded = encodeTyped(type, record.value); + + if (!encoded.ok) { + return encoded; + } + + const ordered = record.endian === "be" ? reverse(encoded.hex) : encoded.hex; + + return pad(ordered, record.pad_to, record.align); +} + +/** + * Encodes an output's object-form `data`. + * + * `{parts: [{type, value}, …]}`, concatenated in order. Only `bytes`, `u8` and `u64`, and + * no endianness or padding to choose — so a document carrying one of those keys here is + * refused rather than quietly encoded as if it had said nothing. + */ +export function encodeDataParts(data: unknown): EncodedBytes { + const declared = asRecord(data)?.parts; + + if (!Array.isArray(declared)) { + return { ok: false, reason: "Object-form data carries no parts list." }; + } + + let hex = ""; + + for (const entry of asArray(declared)) { + const part = asRecord(entry); + + if (!part) { + return { ok: false, reason: "A data part is not a typed value." }; + } + + for (const key of ["endian", "pad_to", "align"]) { + if (key in part) { + return { + ok: false, + reason: `A data part carries ${key}, which this vocabulary does not have.`, + }; + } + } + + const type = part.type; + + if (typeof type !== "string" || !(type === "bytes" || type === "u8" || type === "u64")) { + return { + ok: false, + reason: `A data part is declared ${String(type)}, which object-form data does not have.`, + }; + } + + // Big-endian, because there is no key to say otherwise and a length-prefixed binary + // layout written by hand reads in that order. + const encoded = encodeTyped(type, part.value); + + if (!encoded.ok) { + return encoded; + } + + hex += type === "u64" ? reverse(encoded.hex) : encoded.hex; + } + + return { hex, ok: true }; +} + +function encodeTyped(type: string, value: unknown): EncodedBytes { + const width = LEAF_WIDTHS[type]; + + if (width !== undefined) { + return fromInteger(type, value, width); + } + + if (!(type in LEAF_BYTE_LENGTHS)) { + return { + ok: false, + reason: `An extra leaf is declared ${type}, which is not an encoding this runtime has.`, + }; + } + + const encoded = fromHex(value); + + if (!encoded.ok) { + return encoded; + } + + const required = LEAF_BYTE_LENGTHS[type]; + + if (required !== undefined && encoded.hex.length !== required * 2) { + return { + ok: false, + reason: `A ${type} value is ${encoded.hex.length / 2} bytes, and ${type} is ${required}.`, + }; + } + + return encoded; +} + +/** Little-endian, which is this vocabulary's default; `endian: "be"` reverses it after. */ +function fromInteger(type: string, value: unknown, width: number): EncodedBytes { + const count = asCount(value); + + if (count === undefined) { + return { ok: false, reason: `A ${type} value is not a whole number.` }; + } + + if (count < 0n) { + return { ok: false, reason: `A ${type} value is negative, and these encodings are unsigned.` }; + } + + if (count >= 2n ** BigInt(width * 8)) { + return { ok: false, reason: `A value of ${count} does not fit in a ${type}.` }; + } + + const big = count.toString(16).padStart(width * 2, "0"); + + return { hex: reverse(big), ok: true }; +} + +function fromStateVar(name: string, stateVars: Record<string, unknown>): EncodedBytes { + const declared = asRecord(stateVars[name]); + + if (!declared) { + return { + ok: false, + reason: `An extra leaf names the state variable ${name}, which is not declared.`, + }; + } + + return fromInteger("u8", declared.default_value, 1); +} + +function fromHex(value: unknown): EncodedBytes { + if (typeof value !== "string") { + return { ok: false, reason: "A byte value is not hex." }; + } + + const digits = value.startsWith("0x") ? value.slice(2) : value; + + if (digits.length % 2 !== 0) { + return { ok: false, reason: `"${value}" is not a whole number of bytes.` }; + } + + if (digits.length > 0 && !/^[0-9a-fA-F]+$/.test(digits)) { + return { ok: false, reason: `"${value}" is not hex.` }; + } + + return { hex: digits.toLowerCase(), ok: true }; +} + +/** `pad_to` extends; it never truncates, because a truncated value is a different value. */ +function pad(hex: string, target: unknown, align: unknown): EncodedBytes { + if (target === undefined) { + return { hex, ok: true }; + } + + const length = asCount(target); + + if (length === undefined || length < 0n) { + return { ok: false, reason: "An extra leaf declares a pad_to that is not a length." }; + } + + const have = BigInt(hex.length / 2); + + if (have > length) { + return { + ok: false, + reason: `An extra leaf is ${have} bytes, longer than the ${length} it pads to.`, + }; + } + + const zeros = "00".repeat(Number(length - have)); + + return { hex: align === "left" ? hex + zeros : zeros + hex, ok: true }; +} + +function reverse(hex: string): string { + return (hex.match(/../g) ?? []).reverse().join(""); +} + +function asCount(value: unknown): bigint | undefined { + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number") { + return Number.isSafeInteger(value) ? BigInt(value) : undefined; + } + + return typeof value === "string" && /^-?\d+$/.test(value) ? BigInt(value) : undefined; +} From 23372c1a66b786bd5a87d30f94ccaf28fc52a8fa Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 16:03:11 +0300 Subject: [PATCH 042/124] feat(liquid): derive a covenant address with the leaves it actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A covenant's extra taproot leaves are as much a part of its address as its compile parameters, and until now they could not reach the compiler at all — the binding took source and arguments and nothing else. They are encoded from the utxo type's own declaration and handed over in declaration order, because order is part of the address. A leaf that cannot be encoded refuses the whole derivation rather than producing an address without it. A covenant missing one of its leaves is a different covenant, and a well-formed answer to the wrong question is the failure this whole path exists to prevent. --- .../adapters/smplx/loadSmplxWasm.test.ts | 47 +++++++++++++ .../processConfidentialTransaction/index.ts | 4 +- .../liquid/domain/manifest/covenant.test.ts | 66 ++++++++++++++++++- .../chains/liquid/domain/manifest/covenant.ts | 44 ++++++++++++- smplx | 2 +- 5 files changed, 156 insertions(+), 7 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 19d40b3..e2ab067 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -489,3 +489,50 @@ describe("signing a covenant that authenticates its spender", () => { }); }); }); + +// Extra taproot leaves are payloads appended to the tree beside the program's own leaf, and +// their bytes are as much a part of the covenant address as the parameters are. The shape of +// that tree is consensus-visible: the reference implementation folds it left and every +// deployed covenant address was derived that way, so a tree built any other way produces a +// well-formed address for a contract whose funds sit elsewhere. +describe("extra taproot leaves", () => { + const SOURCE = "fn main() { assert!(jet::eq_32(witness::A, witness::B)); }"; + const LEAF = (byte: string) => `0x${byte.repeat(64)}`; + + function addressWith(...leaves: string[]) { + const contract = new bindings.Contract(SOURCE, undefined, JSON.stringify(leaves)); + + return contract.covenantAddress("liquid-testnet"); + } + + test("no extra leaves derives the address the module always derived", () => { + expect(addressWith()).toBe("tex1phpq2t7y3236nxvudhfx7md9p0h3m9vlsskq5nec9trzcue6k979sk55dr6"); + }); + + test("an extra leaf changes the address", () => { + expect(addressWith(LEAF("11"))).not.toBe(addressWith()); + }); + + test("the leaves' order is part of the address", () => { + expect(addressWith(LEAF("11"), LEAF("22"))).not.toBe(addressWith(LEAF("22"), LEAF("11"))); + }); + + test("the same leaves derive the same address twice", () => { + expect(addressWith(LEAF("11"), LEAF("22"))).toBe(addressWith(LEAF("11"), LEAF("22"))); + }); + + // The format's leaves are any length — `bytes` has no length and `pad_to` exists so a + // value can be shorter — and the module's held them as a fixed thirty-two bytes until + // this. A leaf of another length is a different leaf, not a padded one. + test("a leaf shorter than thirty-two bytes is its own leaf, not a padded one", () => { + expect(addressWith("0x0102")).not.toBe(addressWith(`0x0102${"00".repeat(30)}`)); + }); + + test("a leaf longer than thirty-two bytes is accepted", () => { + expect(addressWith(`0x${"33".repeat(64)}`)).toMatch(/^tex1p/); + }); + + test("refuses a leaf that is not hex rather than deriving something", () => { + expect(() => addressWith("0xzz")).toThrow(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 8adf913..2723301 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -210,8 +210,8 @@ export const createProcessLiquidConfidentialTransaction = ( await context.walletBackend.syncAccount(account); const result = await reviewManifestAction(params, { - compile: ({ argumentsJson, network: target, source }) => - new smplx.Contract(source, argumentsJson).covenantAddress(target), + compile: ({ argumentsJson, extraLeavesJson, network: target, source }) => + new smplx.Contract(source, argumentsJson, extraLeavesJson).covenantAddress(target), scriptPubKeyOf: ({ argumentsJson, source }) => new smplx.Contract(source, argumentsJson).scriptPubKeyHex(network), fundingUtxos: context.walletBackend.getUtxos(account, account.rawPolicyAssetId), diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts index 6cd647a..a2de88b 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts @@ -30,11 +30,21 @@ function request(overrides: Record<string, unknown> = {}) { /** Stands in for the wasm module, recording what it was asked to compile. */ function compiler(address = "tex1p_derived") { - const calls: { argumentsJson: string; network: string; source: string }[] = []; + const calls: { + argumentsJson: string; + extraLeavesJson: string; + network: string; + source: string; + }[] = []; return { calls, - compile: (input: { argumentsJson: string; network: string; source: string }) => { + compile: (input: { + argumentsJson: string; + extraLeavesJson: string; + network: string; + source: string; + }) => { calls.push(input); return address; @@ -90,6 +100,7 @@ describe("covenantMatchesChain", () => { const derivation = { address: "tex1p_derived", argumentsJson: "{}", + extraLeavesJson: "[]", source: SOURCE, utxoType: "p2pk_output", }; @@ -109,3 +120,54 @@ describe("covenantMatchesChain", () => { } }); }); + +// Extra taproot leaves are part of the covenant address, so they are encoded from the utxo +// type's own declaration and handed to the compiler with everything else. A leaf that cannot +// be encoded refuses the derivation rather than producing an address without it. +describe("deriveCovenantAddress with extra leaves", () => { + function manifestWithLeaves(extraLeaves: unknown[], stateVars?: Record<string, unknown>) { + return normaliseManifest({ + utxo_types: { + p2pk_output: { + ...(stateVars ? { state_vars: stateVars } : {}), + script: { extra_leaves: extraLeaves, source: SOURCE_PATH, type: "simplicity" }, + }, + }, + }).manifest; + } + + function derive(extraLeaves: unknown[], stateVars?: Record<string, unknown>) { + const { calls, compile } = compiler(); + + return deriveCovenantAddress(manifestWithLeaves(extraLeaves, stateVars), { + ...request(), + compile, + }).then((result) => ({ calls, result })); + } + + test("encodes each declared leaf and hands them over in order", async () => { + const { calls, result } = await derive([{ type: "u8", value: 1 }, "0xdeadbeef"]); + + expect(result).toMatchObject({ ok: true }); + expect(JSON.parse(calls[0]?.extraLeavesJson ?? "[]")).toEqual(["01", "deadbeef"]); + }); + + test("a utxo type with no extra leaves hands over an empty list", async () => { + const { calls } = await derive([]); + + expect(calls[0]?.extraLeavesJson).toBe("[]"); + }); + + test("resolves a state variable to its default", async () => { + const { calls } = await derive([{ state_var: "counter" }], { counter: { default_value: 3 } }); + + expect(JSON.parse(calls[0]?.extraLeavesJson ?? "[]")).toEqual(["03"]); + }); + + test("refuses the whole derivation when a leaf cannot be encoded", async () => { + const { calls, result } = await derive([{ type: "u128", value: 1 }]); + + expect(result).toMatchObject({ ok: false }); + expect(calls).toHaveLength(0); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts index 4a4906a..aaf1f5e 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts @@ -1,5 +1,6 @@ import { resolveCompileParams } from "./compileParams"; -import { asRecord } from "./json"; +import { encodeLeafItem } from "./encode"; +import { asArray, asRecord } from "./json"; import type { NormalisationNote, NormalisedManifest } from "./normalise"; import type { ReferenceScope } from "./references"; @@ -11,6 +12,8 @@ import type { ReferenceScope } from "./references"; */ export type CompileCovenant = (input: { argumentsJson: string; + /** Already-encoded taproot leaf payloads, appended to the tree in declaration order. */ + extraLeavesJson: string; network: string; source: string; }) => Promise<string> | string; @@ -18,6 +21,8 @@ export type CompileCovenant = (input: { export type CovenantDerivation = { /** The address the wallet derived by rebuilding the contract itself. */ address: string; + /** The extra taproot leaves it was built with, encoded. */ + extraLeavesJson: string; /** * The parameters it was built with, in the compiler's own shape. * @@ -88,17 +93,25 @@ export async function deriveCovenantAddress( return params; } + const leaves = encodeExtraLeaves(asRecord(declared.script), asRecord(declared.state_vars)); + + if (!leaves.ok) { + return { ok: false, reason: `Utxo type "${input.utxoType}": ${leaves.reason}` }; + } + const argumentsJson = JSON.stringify(params.arguments); + const extraLeavesJson = JSON.stringify(leaves.hex); try { const address = await input.compile({ argumentsJson, + extraLeavesJson, network: input.network, source, }); return { - derivation: { address, argumentsJson, source, utxoType: input.utxoType }, + derivation: { address, argumentsJson, extraLeavesJson, source, utxoType: input.utxoType }, ok: true, }; } catch (error) { @@ -135,3 +148,30 @@ export function covenantMatchesChain( `but the funds are at ${onChainAddress}. This is not the contract the site described.`, }; } + +/** + * The encoded payloads of a utxo type's extra taproot leaves, in declaration order. + * + * Order is part of the address, so this preserves it rather than collecting into anything + * that would not. A leaf that cannot be encoded refuses the whole derivation: a covenant + * missing one of its leaves is a different covenant, and deriving an address for it would + * produce a well-formed answer to the wrong question. + */ +function encodeExtraLeaves( + script: Record<string, unknown> | undefined, + stateVars: Record<string, unknown> | undefined, +): { hex: string[]; ok: true } | { ok: false; reason: string } { + const hex: string[] = []; + + for (const item of asArray(script?.extra_leaves)) { + const encoded = encodeLeafItem(item, stateVars ?? {}); + + if (!encoded.ok) { + return encoded; + } + + hex.push(encoded.hex); + } + + return { hex, ok: true }; +} diff --git a/smplx b/smplx index f42c509..36227f7 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit f42c5092875bdc87b8dcf341bb612325202211d3 +Subproject commit 36227f742fa5d41d1f865dc67a0394fce93e0bbf From c07f61fc633411eeb85eea1b2e5ff0781014413a Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 16:14:56 +0300 Subject: [PATCH 043/124] feat(liquid): refuse what this wallet cannot honour, by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fail-closed surface. A construct in a position where being wrong changes what gets signed refuses before anything is built; a decorative one is ignored and the fact recorded. A compiler version other than the one compiled in refuses naming both, from either channel, and silence in both proceeds — four of the seven published manifests declare nothing. A build mode that is neither on nor off refuses rather than being guessed at. Writing it against the published manifest turned most of it into implementing rather than refusing, which is the better half of cornerstone 4: a fail-closed surface that rejects the whole corpus is not fail-closed, it is broken. So the chain, the asset, the script type, the confidential flag, the witness type and its source and sighash, and the protocol's own arithmetic validations are all read and checked now, each refusing only the values this wallet cannot honour. A protocol's validations are checked because the wallet is the only party in a position to honour them — the site asking for the action is who they are written against. --- .../index.test.ts | 1 + .../processConfidentialTransaction/index.ts | 17 +- .../liquid/domain/manifest/covenant.test.ts | 2 + .../chains/liquid/domain/manifest/covenant.ts | 4 + .../chains/liquid/domain/manifest/evaluate.ts | 63 ++++ .../liquid/domain/manifest/refuse.test.ts | 136 ++++++++ .../chains/liquid/domain/manifest/refuse.ts | 328 ++++++++++++++++++ .../liquid/domain/manifest/registry.test.ts | 35 +- .../chains/liquid/domain/manifest/registry.ts | 50 ++- .../liquid/domain/manifest/review.test.ts | 2 + .../chains/liquid/domain/manifest/review.ts | 27 ++ .../chains/liquid/domain/manifest/validate.ts | 71 ++++ smplx | 2 +- 13 files changed, 708 insertions(+), 30 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/refuse.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/refuse.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/validate.ts diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 30aee12..a23c28e 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -73,6 +73,7 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { }, loadSmplx: async () => ({ + compilerVersion: () => "0.6.0", Contract: class { covenantAddress() { return DERIVED; diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 2723301..5214d36 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -210,8 +210,21 @@ export const createProcessLiquidConfidentialTransaction = ( await context.walletBackend.syncAccount(account); const result = await reviewManifestAction(params, { - compile: ({ argumentsJson, extraLeavesJson, network: target, source }) => - new smplx.Contract(source, argumentsJson, extraLeavesJson).covenantAddress(target), + compile: ({ + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + network: target, + source, + }) => + new smplx.Contract( + source, + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + ).covenantAddress(target), + compilerVersion: smplx.compilerVersion(), + policyAsset: account.rawPolicyAssetId, scriptPubKeyOf: ({ argumentsJson, source }) => new smplx.Contract(source, argumentsJson).scriptPubKeyHex(network), fundingUtxos: context.walletBackend.getUtxos(account, account.rawPolicyAssetId), diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts index a2de88b..bb6bacd 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts @@ -20,6 +20,7 @@ function request(overrides: Record<string, unknown> = {}) { return { contractSources: { [SOURCE_PATH]: SOURCE }, declaredTypes: DECLARED_TYPES, + includeDebugSymbols: false, network: "liquid", scope: { params: { pubkey: PUBKEY } }, utxoType: "p2pk_output", @@ -42,6 +43,7 @@ function compiler(address = "tex1p_derived") { compile: (input: { argumentsJson: string; extraLeavesJson: string; + includeDebugSymbols: boolean; network: string; source: string; }) => { diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts index aaf1f5e..0cde0f6 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts @@ -14,6 +14,8 @@ export type CompileCovenant = (input: { argumentsJson: string; /** Already-encoded taproot leaf payloads, appended to the tree in declaration order. */ extraLeavesJson: string; + /** The mode this protocol declares its contracts were built in. */ + includeDebugSymbols: boolean; network: string; source: string; }) => Promise<string> | string; @@ -55,6 +57,7 @@ export async function deriveCovenantAddress( compile: CompileCovenant; contractSources: Record<string, string>; declaredTypes: Record<string, string>; + includeDebugSymbols: boolean; network: string; notes?: NormalisationNote[]; scope: ReferenceScope; @@ -106,6 +109,7 @@ export async function deriveCovenantAddress( const address = await input.compile({ argumentsJson, extraLeavesJson, + includeDebugSymbols: input.includeDebugSymbols, network: input.network, source, }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts b/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts index 6a68344..5a36323 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts @@ -342,3 +342,66 @@ function bounded(value: bigint): EvaluationResult { ? { ok: false, reason: "This amount leaves the 64-bit range these numbers are held in." } : { ok: true, value }; } + +/** + * Evaluates a validation's condition: two amounts and one comparison between them. + * + * Deliberately not a boolean expression language. Every validation in the corpus is one + * comparison, and a rule this runtime read only half of would be worse than one it refused + * outright — a validation exists to stop a transaction its protocol considers invalid, so + * getting it wrong permits exactly what it was written to prevent. + */ +export function evaluateCondition( + text: string, + site: ReferenceSiteKind, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { met: boolean; ok: true } | { ok: false; reason: string } { + const split = /^(?<left>.+?)\s*(?<operator>>=|<=|==|!=|>|<)\s*(?<right>.+)$/.exec(text.trim()); + const operator = split?.groups?.operator; + + if (!operator) { + return { + ok: false, + reason: `"${text}" is not a comparison, and this runtime reads no other condition.`, + }; + } + + const left = evaluateExpression(split?.groups?.left ?? "", site, scope, notes); + + if (!left.ok) { + return left; + } + + const right = evaluateExpression(split?.groups?.right ?? "", site, scope, notes); + + if (!right.ok) { + return right; + } + + switch (operator) { + case "!=": { + return { met: left.value !== right.value, ok: true }; + } + + case "<": { + return { met: left.value < right.value, ok: true }; + } + + case "<=": { + return { met: left.value <= right.value, ok: true }; + } + + case "==": { + return { met: left.value === right.value, ok: true }; + } + + case ">": { + return { met: left.value > right.value, ok: true }; + } + + default: { + return { met: left.value >= right.value, ok: true }; + } + } +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/refuse.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/refuse.test.ts new file mode 100644 index 0000000..61731fc --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/refuse.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; + +import { normaliseManifest } from "./normalise"; +import { buildMode, refuseUnsupported } from "./refuse"; + +// Three refusals, each naming its reason. The rule they enforce is the format's own: the +// ELIP draft says a tool that does not implement an extension must reject a manifest using +// its fields rather than ignoring them. What this adds is that a refusal is a refusal — +// there is no shape of any of these that returns a warning to click through. + +const SHIPPED = "0.6.0"; + +function refuse(raw: Record<string, unknown>, sources: Record<string, string> = {}) { + const result = refuseUnsupported(normaliseManifest(raw).manifest, { + compilerVersion: SHIPPED, + contractSources: sources, + policyAsset: "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", + }); + + return result ? result.reason : ""; +} + +describe("an unrecognised construct in a load-bearing position", () => { + test("refuses, naming the construct and where it was", () => { + const reason = refuse({ + actions: { Pay: { inputs: [{ id: "a", teleport: true, utxo_source: "wallet" }] } }, + }); + + expect(reason).toContain("teleport"); + expect(reason).toContain("input a"); + }); + + test("refuses a construct the format has and this runtime does not implement", () => { + expect(refuse({ actions: { Open: { on_validate: "fn main() { }" } } })).toContain( + "on_validate", + ); + }); + + // AC-02's other half, built in slice 4: a decorative construct is ignored rather than + // refused, and the review carries the record of it. + test("says nothing about a decorative construct", () => { + expect(refuse({ attestation_version: "1" })).toBe(""); + }); + + test("says nothing about an unrecognised key inside a display block", () => { + expect(refuse({ actions: { Pay: { ui: { icon: "vault.svg" } } } })).toBe(""); + }); + + test("says nothing about the published p2pk manifest's own constructs it acts on", () => { + expect(refuse({ utxo_types: { v: { script: { source: "./v.simf" } } } })).toBe(""); + }); +}); + +describe("a foreign compiler version", () => { + test("refuses a manifest declaring one, naming both versions", () => { + const reason = refuse({ simplicity_hl_version: "0.7.0" }); + + expect(reason).toContain("0.7.0"); + expect(reason).toContain(SHIPPED); + }); + + test("proceeds when the manifest declares the version that ships", () => { + expect(refuse({ simplicity_hl_version: SHIPPED })).toBe(""); + }); + + // Four of the seven published manifests declare no version at all. Refusing on silence + // would reject them for a reason that has nothing to do with trust. + test("proceeds when the manifest declares nothing", () => { + expect(refuse({})).toBe(""); + }); + + test("refuses a contract source whose simc directive asks for another version", () => { + const reason = refuse({}, { "./a.simf": 'simc "0.5.0";\nfn main() { }' }); + + expect(reason).toContain("0.5.0"); + expect(reason).toContain("./a.simf"); + }); + + test("proceeds when the source asks for the version that ships", () => { + expect(refuse({}, { "./a.simf": `simc "${SHIPPED}";\nfn main() { }` })).toBe(""); + }); + + test("proceeds when the source names no version", () => { + expect(refuse({}, { "./a.simf": "fn main() { }" })).toBe(""); + }); + + test("accepts a range the shipped version satisfies", () => { + expect(refuse({}, { "./a.simf": 'simc ">=0.6.0";\nfn main() { }' })).toBe(""); + }); + + test("refuses a range the shipped version does not satisfy, naming it", () => { + expect(refuse({}, { "./a.simf": 'simc ">=0.7.0";\nfn main() { }' })).toContain(">=0.7.0"); + }); +}); + +// AC-15. The mode is not an attack surface — the wallet rebuilds the contract itself and +// refuses unless the derived address matches where the funds sit — so this decides what the +// wallet computes, never what it checks against. No setting governs it and none exists. +describe("the build mode a protocol declares", () => { + test("a protocol declaring debug symbols is built with them", () => { + expect(buildMode(normaliseManifest({ compile_debug_symbols: true }).manifest)).toBe(true); + }); + + test("a protocol declaring them off is built without them", () => { + expect(buildMode(normaliseManifest({ compile_debug_symbols: false }).manifest)).toBe(false); + }); + + test("a protocol declaring nothing is built plainly", () => { + expect(buildMode(normaliseManifest({}).manifest)).toBe(false); + }); + + test("a declaration that is not a mode is refused rather than guessed at", () => { + expect(refuse({ compile_debug_symbols: "yes" })).toContain("compile_debug_symbols"); + }); +}); + +// `chain` names the network family rather than one of its networks: every published manifest +// says "liquid", and a protocol is not written twice for testnet and mainnet. +describe("a protocol for another chain", () => { + test("accepts the family every published manifest declares", () => { + expect(refuse({ chain: "liquid" })).toBe(""); + }); + + test("accepts the other Liquid network names", () => { + expect(refuse({ chain: "liquid-testnet" })).toBe(""); + expect(refuse({ chain: "elements-regtest" })).toBe(""); + }); + + test("refuses one this wallet cannot build for, naming it", () => { + expect(refuse({ chain: "bitcoin" })).toContain("bitcoin"); + }); + + test("proceeds when the manifest declares no chain", () => { + expect(refuse({})).toBe(""); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/refuse.ts b/apps/extension/src/core/chains/liquid/domain/manifest/refuse.ts new file mode 100644 index 0000000..f9099fa --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/refuse.ts @@ -0,0 +1,328 @@ +import { asArray, asRecord } from "./json"; +import type { NormalisedManifest } from "./normalise"; +import { loadBearing, inspectConstructs } from "./registry"; + +export type Refusal = { reason: string }; + +/** + * Every reason this runtime will not build an action, checked before anything is built. + * + * The rule is the format's own rather than house style: a tool that does not implement an + * extension must reject a manifest using its fields rather than ignore them. What is added + * here is that a refusal is a refusal — none of these returns a warning, and there is no + * shape of this function that a person could click through. + */ +export function refuseUnsupported( + manifest: NormalisedManifest, + input: { + compilerVersion: string; + contractSources: Record<string, string>; + /** The asset this wallet pays fees in and is the only one it moves today. */ + policyAsset: string; + }, +): Refusal | undefined { + return ( + refuseForeignChain(manifest) ?? + refuseUnrecognisedConstruct(manifest) ?? + refuseForeignCompiler(manifest, input) ?? + refuseUnreadableBuildMode(manifest) ?? + refuseUnproducibleWitness(manifest) ?? + refuseForeignAsset(manifest, input.policyAsset) ?? + refuseUnbuildableUtxoType(manifest, input.policyAsset) + ); +} + +/** + * Whether this protocol's contracts are built with debug symbols. + * + * The flag changes the CMR and therefore the covenant address, so the wallet follows the + * mode the protocol states and builds plainly when it states nothing. That is not a hole in + * the address check: whatever a site declares, the wallet rebuilds the contract and refuses + * unless the result matches where the funds actually sit, so a misdeclared mode produces a + * refusal rather than an exploit. It decides what the wallet computes, never what it + * compares against — which is why no user-facing setting governs it and none exists. + */ +export function buildMode(manifest: NormalisedManifest): boolean { + return manifest.node.compile_debug_symbols === true; +} + +/** + * A protocol for a chain this wallet does not build on. + * + * `chain` names the network family rather than one of its networks — every published + * manifest says `liquid`, and a protocol is not written twice for testnet and mainnet — so + * this checks the family and leaves which Liquid network to the wallet's own configuration. + * A manifest naming anything else describes a transaction this wallet cannot make. + */ +function refuseForeignChain(manifest: NormalisedManifest): Refusal | undefined { + const declared = manifest.node.chain; + + if (declared === undefined || (typeof declared === "string" && LIQUID_CHAINS.has(declared))) { + return undefined; + } + + return { + reason: + `This protocol is for ${JSON.stringify(declared)}, and this wallet builds Liquid ` + + "transactions.", + }; +} + +/** The names a Liquid protocol's `chain` can carry. */ +const LIQUID_CHAINS = new Set(["elements", "elements-regtest", "liquid", "liquid-testnet"]); + +/** + * The first construct the runtime does not act on in a position where being wrong could + * change what gets signed. + * + * One at a time rather than all of them: the reader of this message is deciding whether to + * trust a site, and a list of eleven field names is not more useful than the first. + */ +function refuseUnrecognisedConstruct(manifest: NormalisedManifest): Refusal | undefined { + const found = loadBearing(inspectConstructs(manifest))[0]; + + if (!found) { + return undefined; + } + + return { + reason: found.declared + ? `This protocol uses "${found.key}" at ${found.at}, which this wallet does not implement. ` + + "It will not sign a transaction built from a document it has only partly read." + : `This protocol uses "${found.key}" at ${found.at}, which this wallet does not recognise. ` + + "It will not sign a transaction built from a document it has only partly read.", + }; +} + +/** + * A compiler version other than the one compiled into this wallet. + * + * Exactly one version ships, as a crate inside the signing module rather than as anything + * fetched, so there is no allowlist and nothing to revoke — and a manifest asking for + * another version cannot be honoured at all, because a different compiler derives a + * different address for the same contract. + * + * Two channels declare it and both are checked against the same version, so there is no + * precedence to define. Silence in both proceeds: four of the seven published manifests + * declare nothing, and refusing them would be refusing for a reason unrelated to trust. + */ +function refuseForeignCompiler( + manifest: NormalisedManifest, + input: { compilerVersion: string; contractSources: Record<string, string> }, +): Refusal | undefined { + const declared = manifest.node.simplicity_hl_version; + + if (typeof declared === "string" && declared !== input.compilerVersion) { + return { + reason: + `This protocol asks for SimplicityHL ${declared} and this wallet has ` + + `${input.compilerVersion}. A different compiler derives a different address for the ` + + "same contract, so there is nothing safe to build.", + }; + } + + for (const [path, source] of Object.entries(input.contractSources)) { + const range = simcDirective(source); + + if (range !== undefined && !satisfies(input.compilerVersion, range)) { + return { + reason: + `The contract at ${path} asks for SimplicityHL ${range} and this wallet has ` + + `${input.compilerVersion}.`, + }; + } + } + + return undefined; +} + +/** A declared build mode that is neither on nor off says nothing this wallet can follow. */ +function refuseUnreadableBuildMode(manifest: NormalisedManifest): Refusal | undefined { + const declared = manifest.node.compile_debug_symbols; + + if (declared === undefined || typeof declared === "boolean") { + return undefined; + } + + return { + reason: + `This protocol declares compile_debug_symbols as ${JSON.stringify(declared)}, which is ` + + "neither on nor off. The wallet builds each contract the way its protocol states, and " + + "cannot follow a statement it cannot read.", + }; +} + +/** + * A witness this runtime cannot produce. + * + * The registry says the three keys are read; reading them is not the same as honouring every + * value they can hold. A `simplicityhl` witness is a value the site computes and we do not, + * a source other than the wallet is a key we do not hold, and a sighash type other than the + * one we sign is a signature over something else. Each of those, signed as if it had said + * what we can do, is a signature over a transaction nobody agreed to. + */ +function refuseUnproducibleWitness(manifest: NormalisedManifest): Refusal | undefined { + for (const action of manifest.actions) { + for (const declared of asArray(action.node.inputs)) { + const input = asRecord(declared); + const id = typeof input?.id === "string" ? input.id : "(unnamed)"; + + for (const [name, entry] of Object.entries(asRecord(input?.witnesses) ?? {})) { + const witness = asRecord(entry); + const at = `${action.name} / input ${id} / witness ${name}`; + + if (witness?.type !== "Signature") { + return { + reason: + `The witness ${name} at ${at} is a ${String(witness?.type)}, and this wallet ` + + "can only produce a signature.", + }; + } + + const source = asRecord(witness.source)?.type; + + if (source !== "wallet") { + return { + reason: + `The witness ${name} at ${at} is sourced from ${String(source)}, and this ` + + "wallet can only sign with a key it holds.", + }; + } + + const sigType = witness.sig_type; + + if (sigType !== undefined && sigType !== "sig_hash_all") { + return { + reason: + `The witness ${name} at ${at} asks for ${String(sigType)}, and this wallet ` + + "signs over the whole transaction.", + }; + } + } + } + } + + return undefined; +} + +/** + * An input or output in an asset this wallet does not move. + * + * Only the policy asset today. An input naming another asset would be funded from L-BTC and + * an output in another asset would be paid in L-BTC, and neither is a smaller version of + * what the manifest asked for — it is a different transaction. + */ +function refuseForeignAsset( + manifest: NormalisedManifest, + policyAsset: string, +): Refusal | undefined { + const allowed = new Set(["lbtc", policyAsset.toLowerCase()]); + + for (const action of manifest.actions) { + for (const kind of ["inputs", "outputs"] as const) { + for (const declared of asArray(action.node[kind])) { + const entry = asRecord(declared); + const asset = entry?.asset; + + if (typeof asset === "string" && !allowed.has(asset.toLowerCase())) { + const id = typeof entry?.id === "string" ? entry.id : "(unnamed)"; + + return { + reason: + `${action.name} moves ${asset} at ${id}, and this wallet moves only the ` + + "network's own asset.", + }; + } + } + } + } + + return undefined; +} + +/** + * A utxo type this wallet cannot build or spend. + * + * Three things about one, and each is a value the wallet would otherwise act on as if it had + * said something else: a script that is not a Simplicity program, a covenant declared + * confidential — which Simplicity cannot read, so the program could never introspect its own + * value — and an asset the wallet does not move. + */ +function refuseUnbuildableUtxoType( + manifest: NormalisedManifest, + policyAsset: string, +): Refusal | undefined { + const allowed = new Set(["lbtc", policyAsset.toLowerCase()]); + + for (const [name, declared] of Object.entries(manifest.utxoTypes)) { + const utxoType = asRecord(declared); + const scriptType = asRecord(utxoType?.script)?.type; + + if (scriptType !== undefined && scriptType !== "simplicity") { + return { + reason: + `The ${name} contract is a ${String(scriptType)} script, and this wallet builds ` + + "Simplicity covenants.", + }; + } + + if (utxoType?.confidential === true) { + return { + reason: + `The ${name} covenant is declared confidential. A Simplicity program cannot read a ` + + "confidential commitment, so it could never check its own value.", + }; + } + + const asset = utxoType?.asset; + + if (typeof asset === "string" && !allowed.has(asset.toLowerCase())) { + return { + reason: + `The ${name} covenant holds ${asset}, and this wallet moves only the network's own ` + + "asset.", + }; + } + } + + return undefined; +} + +/** The `simc "<range>"` directive a contract source may open with. */ +function simcDirective(source: string): string | undefined { + return /(?:^|\n)\s*simc\s+"(?<range>[^"]+)"/.exec(source)?.groups?.range; +} + +/** + * Whether the shipped version satisfies what a source asks for. + * + * Deliberately narrow: an exact version, or a `>=` lower bound, which is what the corpus + * and upstream's own documentation use. Any other syntax is not interpreted generously — + * an unparsed range is treated as unsatisfied, because guessing at a constraint that + * decides whether an address can be derived is the failure this refusal exists to prevent. + */ +function satisfies(shipped: string, range: string): boolean { + const trimmed = range.trim(); + const lowerBound = /^>=\s*(?<version>[0-9]+(?:\.[0-9]+){0,2})$/.exec(trimmed)?.groups?.version; + + if (lowerBound !== undefined) { + return compare(shipped, lowerBound) >= 0; + } + + return trimmed === shipped; +} + +function compare(left: string, right: string): number { + const one = left.split(".").map(Number); + const other = right.split(".").map(Number); + + for (let at = 0; at < Math.max(one.length, other.length); at += 1) { + const difference = (one[at] ?? 0) - (other[at] ?? 0); + + if (difference !== 0) { + return difference; + } + } + + return 0; +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts index 64ec27b..3d8693d 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts @@ -31,7 +31,7 @@ describe("inspectConstructs", () => { }); test("names where it found something, in the document's own terms", () => { - const finding = at(inspect(flat), "validations"); + const finding = at(inspect({ actions: { Pay: { args: { a: 1 } } } }), "args"); expect(finding?.at).toBe("action Pay"); }); @@ -69,7 +69,7 @@ describe("inspectConstructs", () => { // The refusal itself is ISSUE-021. What this slice owes it is the distinction. describe("load-bearing constructs are separated out", () => { test("a construct the format declares and this runtime does not implement", () => { - const finding = at(inspect(flat), "validations"); + const finding = at(inspect({ actions: { Pay: { create_instance: {} } } }), "create_instance"); expect(finding).toMatchObject({ declared: true, loadBearing: true }); }); @@ -104,20 +104,36 @@ describe("inspectConstructs", () => { }); describe("the sites it reaches", () => { + // `simplicity_type` rather than `sig_type`: the runtime reads the witness's type, + // source and sighash type now, and a construct it reads is not a finding. test("a witness on an input", () => { - const findings = inspect(flat); + const findings = inspect({ + actions: { + Receive: { + inputs: [ + { + id: "p2pk_in", + utxo_source: { utxo_type: "v" }, + witnesses: { SIGNATURE: { simplicity_type: "u256", type: "Signature" } }, + }, + ], + }, + }, + }); - expect(at(findings, "sig_type")?.at).toBe( + expect(at(findings, "simplicity_type")?.at).toBe( "action Receive / input p2pk_in / witness SIGNATURE", ); }); + // The script site's own keys are all read now, so an unrecognised one is what is left + // to find there. test("a script under a utxo type", () => { const findings = inspect({ - utxo_types: { vault: { script: { extra_leaves: [], source: "./a.simf" } } }, + utxo_types: { vault: { script: { salt: "0x00", source: "./a.simf" } } }, }); - expect(at(findings, "extra_leaves")?.at).toBe("utxo type vault / script"); + expect(at(findings, "salt")?.at).toBe("utxo type vault / script"); }); test("a parameter definition", () => { @@ -128,10 +144,13 @@ describe("inspectConstructs", () => { expect(at(findings, "default")?.at).toBe("action Pay / param owner"); }); + // The rule itself is read now; an unrecognised key beside it is what is left to find. test("a validation rule", () => { - const findings = inspect(flat); + const findings = inspect({ + actions: { Pay: { validations: [{ id: "amount_nonzero", severity: "warn" }] } }, + }); - expect(at(findings, "rule")?.at).toBe("action Pay / validation amount_nonzero"); + expect(at(findings, "severity")?.at).toBe("action Pay / validation amount_nonzero"); }); test("a grouped method is reached the same way a flat action is", () => { diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts b/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts index 488868a..8ab2190 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts @@ -93,21 +93,23 @@ const SITES = { outputs: READ, params: READ, ui: SHOWN, - validations: UNIMPLEMENTED, + validations: READ, witnesses: UNIMPLEMENTED, }, unknownIsLoadBearing: true, }, input: { constructs: { - amount_sat: UNIMPLEMENTED, - asset: UNIMPLEMENTED, + amount_sat: READ, + asset: READ, description: SHOWN, from_address: UNIMPLEMENTED, id: READ, issuance: UNIMPLEMENTED, on_resolved: UNIMPLEMENTED, - optional: UNIMPLEMENTED, + // The action tolerates this input's absence. The wallet includes what it is given + // and never drops one, which is inside what the declaration permits. + optional: SHOWN, // Covenants depend on input and output ordering and no implementation enforces // this, so a manifest asking for index 0 and getting 1 builds a transaction the // covenant rejects on chain. @@ -115,7 +117,9 @@ const SITES = { sequence: UNIMPLEMENTED, ui: SHOWN, utxo_source: READ, - witnesses: UNIMPLEMENTED, + // Read for the one thing the runtime can act on — which witness the signer must + // fill. The witness site below carries what is and is not honoured inside one. + witnesses: READ, }, unknownIsLoadBearing: true, }, @@ -126,9 +130,9 @@ const SITES = { // implementation including the reference one. attestation_version: UNREAD, actions: READ, - chain: UNIMPLEMENTED, + chain: READ, classes: READ, - compile_debug_symbols: UNIMPLEMENTED, + compile_debug_symbols: READ, confidential_outputs: UNIMPLEMENTED, description: SHOWN, errors: SHOWN, @@ -136,7 +140,7 @@ const SITES = { manifest_version: READ, params: READ, protocol: SHOWN, - simplicity_hl_version: UNIMPLEMENTED, + simplicity_hl_version: READ, source: UNIMPLEMENTED, utxo_types: READ, }, @@ -145,14 +149,14 @@ const SITES = { output: { constructs: { amount_sat: READ, - asset: UNIMPLEMENTED, + asset: READ, condition: UNIMPLEMENTED, confidential: UNIMPLEMENTED, data: UNIMPLEMENTED, description: SHOWN, destination: READ, id: READ, - optional: UNIMPLEMENTED, + optional: SHOWN, required_index: UNIMPLEMENTED, ui: SHOWN, }, @@ -175,9 +179,9 @@ const SITES = { script: { constructs: { compile_params: READ, - extra_leaves: UNIMPLEMENTED, + extra_leaves: READ, source: READ, - type: UNIMPLEMENTED, + type: READ, }, unknownIsLoadBearing: true, }, @@ -194,11 +198,11 @@ const SITES = { }, utxoType: { constructs: { - asset: UNIMPLEMENTED, - confidential: UNIMPLEMENTED, + asset: READ, + confidential: READ, description: SHOWN, script: READ, - state_vars: UNIMPLEMENTED, + state_vars: READ, }, unknownIsLoadBearing: true, }, @@ -208,17 +212,25 @@ const SITES = { error: SHOWN, error_code: SHOWN, id: SHOWN, - rule: UNIMPLEMENTED, + rule: READ, }, unknownIsLoadBearing: true, }, + /** + * A witness the spend has to supply. + * + * Three keys are read and each is also checked, because reading a key is not the same as + * honouring every value it can hold: a witness type, a source or a sighash type this + * runtime cannot produce refuses by name rather than being signed as if it had said + * something else. Those checks live in the refusal surface, not here. + */ witness: { constructs: { description: SHOWN, - sig_type: UNIMPLEMENTED, + sig_type: READ, simplicity_type: UNIMPLEMENTED, - source: UNIMPLEMENTED, - type: UNIMPLEMENTED, + source: READ, + type: READ, value: UNIMPLEMENTED, }, unknownIsLoadBearing: true, diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts index 1bb5c89..0cc5522 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -36,6 +36,8 @@ const fundingUtxos = [ /** The three dependencies every case shares; individual tests override what they exercise. */ const deps = { compile, + compilerVersion: "0.6.0", + policyAsset: "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", fundingUtxos, network: "liquid", readFeeRate, diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts index 2d32c90..c10219b 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -13,10 +13,12 @@ import { } from "./normalise"; import { planAction } from "./plan"; import type { ReferenceScope } from "./references"; +import { buildMode, refuseUnsupported } from "./refuse"; import { type ConstructFinding, ignored, inspectConstructs } from "./registry"; import { resolveActionRequirements } from "./requirements"; import { covenantSites } from "./sites"; import type { ParsedLiquidProcessCtParams } from "./types"; +import { checkValidations } from "./validate"; /** * What the wallet established for itself about one covenant this action touches. @@ -130,6 +132,10 @@ export async function reviewManifestAction( network: string; readFeeRate: ReadFeeRate; readTxOut: ReadTxOut; + /** The SimplicityHL version compiled into this wallet, which is the only one it has. */ + compilerVersion: string; + /** The asset this wallet pays fees in and is the only one it moves. */ + policyAsset: string; /** Compiles a contract to the scriptPubKey it locks to, for the hashes a manifest computes. */ scriptPubKeyOf: CompileScriptPubKey; walletScriptPubKeyHex: string; @@ -140,6 +146,18 @@ export async function reviewManifestAction( const deployment = normaliseInstance(request.instance); const notes: NormalisationNote[] = [...normalised.notes, ...deployment.notes]; + // Everything the wallet will not build, before it builds anything. A refusal here is a + // refusal: nothing downstream turns one into a prompt. + const refusal = refuseUnsupported(manifest, { + compilerVersion: input.compilerVersion, + contractSources: request.contractSources, + policyAsset: input.policyAsset, + }); + + if (refusal) { + return { reason: refusal.reason, refused: true }; + } + const requirements = resolveActionRequirements(request, manifest); if (requirements.missing.length > 0) { @@ -187,6 +205,7 @@ export async function reviewManifestAction( compile: input.compile, contractSources: request.contractSources, declaredTypes, + includeDebugSymbols: buildMode(manifest), network: input.network, notes, scope, @@ -309,6 +328,14 @@ export async function reviewManifestAction( return { reason: plan.reason, refused: true }; } + // The protocol's own rules about this action, checked once its amounts are known — a rule + // comparing an amount cannot be checked before there is one. + const failed = checkValidations(action, { ...scope, fee: estimatedFee }, notes); + + if (failed) { + return { reason: failed.reason, refused: true }; + } + const covenantScripts = new Map(covenants.map((found) => [found.utxoType, found.address])); const outputs: ReviewedOutput[] = []; diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/validate.ts b/apps/extension/src/core/chains/liquid/domain/manifest/validate.ts new file mode 100644 index 0000000..1cf31b6 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/validate.ts @@ -0,0 +1,71 @@ +import { evaluateCondition } from "./evaluate"; +import { asArray, asRecord } from "./json"; +import type { NormalisationNote, NormalisedAction } from "./normalise"; +import type { ReferenceScope } from "./references"; + +export type ValidationFailure = { reason: string }; + +/** + * Checks the rules an action declares about itself, before anything is built. + * + * A validation exists so a protocol can stop a transaction it considers invalid, and the + * wallet is the only thing in a position to honour that — the site asking for the action is + * the party the rule is written against. So a rule that fails refuses, and a rule this + * runtime cannot read refuses too: reading half a rule permits exactly what it was written + * to prevent. + * + * Only `arithmetic` rules, which is every rule the corpus contains. A `simplicity_hl` rule + * is a contract to execute rather than a condition to evaluate, and `utxo_exists` is a chain + * question nothing here asks; both are refused by name. + */ +export function checkValidations( + action: NormalisedAction, + scope: ReferenceScope, + notes?: NormalisationNote[], +): ValidationFailure | undefined { + for (const declared of asArray(action.node.validations)) { + const validation = asRecord(declared); + const id = typeof validation?.id === "string" ? validation.id : "(unnamed)"; + const rule = asRecord(validation?.rule); + + if (rule?.type !== "arithmetic") { + return { + reason: + `The rule ${id} is a ${String(rule?.type)} check, which this wallet cannot make. ` + + "It will not build a transaction its own protocol asked it to check first.", + }; + } + + if (typeof rule.expr !== "string") { + return { reason: `The rule ${id} states no condition.` }; + } + + const met = evaluateCondition(rule.expr, "expression", scope, notes); + + if (!met.ok) { + return { reason: `The rule ${id} could not be checked: ${met.reason}` }; + } + + if (!met.met) { + return { reason: message(validation, id) }; + } + } + + return undefined; +} + +/** + * What the person is told when a rule fails. + * + * The protocol's own message when it wrote one, because it knows what the rule means and + * this wallet does not — attributed to the protocol rather than stated as the wallet's own + * finding, which is the same rule that governs every other piece of site-authored text. + */ +function message(validation: Record<string, unknown> | undefined, id: string): string { + const error = validation?.error; + const declared = typeof error === "string" ? error : asRecord(error)?.message; + + return typeof declared === "string" + ? `This protocol refuses the action: ${declared}` + : `This protocol's own rule ${id} is not satisfied by this action.`; +} diff --git a/smplx b/smplx index 36227f7..37f9e67 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 36227f742fa5d41d1f865dc67a0394fce93e0bbf +Subproject commit 37f9e6763b4fecbaccf3df1dc82ab97e7d1f01f3 From 474ec67d63c73260843eb88512ba928143bc3446 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 16:21:20 +0300 Subject: [PATCH 044/124] feat(liquid): refuse a transaction that spends what nobody asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet knows the whole expected set before the signing module runs: the covenant inputs the action requires, verified against the chain, and the wallet outputs the wallet itself chose. Anything else being spent is a transaction nobody agreed to, and nothing is returned. The outpoints are read out of the finished transaction's own bytes rather than asked for. A module's account of what it spent is the one source that cannot answer whether the module did something it was not asked to — it would be the same component reporting on itself — and the bytes are what would reach the network. The guard this replaces compared lwk's reported signatures before and after signing, which needed lwk to be the signer; on this path it is not. The shape is kept and the mechanism is new. A missing input counts too: a transaction that spends less than the action requires is not a safer version of it. The parser is checked against transactions the real module signed, because fixtures written by the same hand as the parser can be wrong together. --- .../adapters/smplx/loadSmplxWasm.test.ts | 78 ++++++++++ .../index.test.ts | 100 ++++++++++-- .../processConfidentialTransaction/index.ts | 18 +++ .../liquid/domain/manifest/inputGuard.test.ts | 102 +++++++++++++ .../liquid/domain/manifest/inputGuard.ts | 65 ++++++++ .../liquid/domain/manifest/spentInputs.ts | 144 ++++++++++++++++++ 6 files changed, 493 insertions(+), 14 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/spentInputs.ts diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index e2ab067..7a4fde5 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -4,6 +4,9 @@ import { createRequire } from "node:module"; import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js"; +import { guardSpentInputs } from "../../domain/manifest/inputGuard"; +import { spentInputs } from "../../domain/manifest/spentInputs"; + // Exercises the exact bindings `loadSmplxWasm` consumes. The only difference is where // the module bytes come from: the extension fetches them through a Vite asset URL, this // reads them off disk. Everything after instantiation — the `__wbg_set_wasm` handshake, @@ -536,3 +539,78 @@ describe("extra taproot leaves", () => { expect(() => addressWith("0xzz")).toThrow(); }); }); + +// The input guard reads the outpoints out of a finished transaction's own bytes rather than +// asking the module what it spent — a module's account of itself cannot answer whether the +// module did something it was not asked to. That only works if the parser agrees with what +// the module actually serialises, which is what this checks. +describe("what a signed transaction says it spends", () => { + const TXID = "4".repeat(64); + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + + function txOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const value = sats.toString(16).padStart(16, "0"); + const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${len}${scriptHex}`; + } + + function signSpending(vouts: number[]) { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + + try { + for (const vout of vouts) { + builder.addWalletInput(TXID, vout, txOut(200_000n, signer.scriptPubKeyHex())); + } + + builder.addOutput(signer.scriptPubKeyHex(), 10_000n, POLICY_ASSET); + + const signed = signer.finalizeTransaction(builder, 1000, signer.scriptPubKeyHex()); + const hex = signed.hex; + + signed.free(); + + return hex; + } finally { + builder.free(); + signer.free(); + } + } + + test("the parser reads back the outpoint that went in", () => { + const result = spentInputs(signSpending([3])); + + expect(result.ok && result.spent).toEqual([{ txid: TXID, vout: 3 }]); + }); + + test("and reads several back in the order they were added", () => { + const result = spentInputs(signSpending([1, 5])); + + expect(result.ok && result.spent).toEqual([ + { txid: TXID, vout: 1 }, + { txid: TXID, vout: 5 }, + ]); + }); + + test("the guard passes a transaction spending exactly what the wallet chose", () => { + const chosen = [ + { txid: TXID, vout: 1 }, + { txid: TXID, vout: 5 }, + ]; + + expect( + guardSpentInputs(signSpending([1, 5]), { covenantInputs: [], walletInputs: chosen }), + ).toEqual({ ok: true }); + }); + + test("and refuses one spending an outpoint the wallet did not choose", () => { + const result = guardSpentInputs(signSpending([1, 5]), { + covenantInputs: [], + walletInputs: [{ txid: TXID, vout: 1 }], + }); + + expect(result.ok).toBe(false); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index a23c28e..93b1339 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -19,6 +19,26 @@ const DERIVED = "tex1p_derived"; const WALLET_ADDRESS = "tex1q_wallet"; const WALLET_SCRIPT = "0014" + "11".repeat(20); const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const FUNDING_TXID = "d".repeat(64); + +/** + * An Elements transaction serialised as far as its inputs, which is what the input guard + * reads. The substituted module builds one from what it was actually told to spend, so the + * guard is exercised against the shape of the request rather than against a constant that + * would agree with it whatever happened. + */ +function serialise(spends: { txid: string; vout: number }[]): string { + const inputs = spends + .map(({ txid, vout }) => { + const reversed = (txid.match(/../g) ?? []).reverse().join(""); + const index = vout.toString(16).padStart(8, "0").match(/../g)!.reverse().join(""); + + return `${reversed}${index}00ffffffff`; + }) + .join(""); + + return `0200000001${spends.length.toString(16).padStart(2, "0")}${inputs}`; +} function params(overrides: Record<string, unknown> = {}) { return { @@ -45,7 +65,7 @@ function context(): LiquidProcessCtContext { { amount: "1000000", spendable: true, - txid: "d".repeat(64), + txid: FUNDING_TXID, txOut: "00", vout: 0, }, @@ -58,13 +78,6 @@ function context(): LiquidProcessCtContext { type Recorded = { broadcasts: { txHex: string }[]; mnemonicCalls: number }; function dependencies(recorded: Recorded): LiquidProcessCtDependencies { - const signed = { - feeSats: 500n, - free: () => undefined, - hex: "02000000deadbeef", - txid: "e".repeat(64), - }; - return { broadcastTransaction: async ({ txHex }) => { recorded.broadcasts.push({ txHex }); @@ -78,16 +91,29 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { covenantAddress() { return DERIVED; } + scriptPubKeyHex() { + return "5120aabb"; + } }, TransactionBuilder: class { - addCovenantInput() {} + spends: { txid: string; vout: number }[] = []; + addCovenantInput(txid: string, vout: number) { + this.spends.push({ txid, vout }); + } addOutput() {} - addWalletInput() {} + addWalletInput(txid: string, vout: number) { + this.spends.push({ txid, vout }); + } free() {} }, WalletSigner: class { - finalizeTransaction() { - return signed; + finalizeTransaction(builder: { spends: { txid: string; vout: number }[] }) { + return { + feeSats: 500n, + free: () => undefined, + hex: serialise(builder.spends), + txid: "e".repeat(64), + }; } free() {} scriptPubKeyHex() { @@ -126,7 +152,7 @@ describe("processLiquidConfidentialTransaction", () => { const result = await method(params(), context()); expect(result).toMatchObject({ broadcast: false, feeSats: "500" }); - expect(result.transactionHex).toBe("02000000deadbeef"); + expect(result.transactionHex).toBe(serialise([{ txid: FUNDING_TXID, vout: 0 }])); expect(recorded.broadcasts).toHaveLength(0); }); @@ -135,7 +161,7 @@ describe("processLiquidConfidentialTransaction", () => { const result = await method(params({ broadcast: true }), context()); - expect(recorded.broadcasts).toEqual([{ txHex: "02000000deadbeef" }]); + expect(recorded.broadcasts).toEqual([{ txHex: serialise([{ txid: FUNDING_TXID, vout: 0 }]) }]); expect(result).toMatchObject({ broadcast: true, txid: "f".repeat(64) }); }); @@ -195,3 +221,49 @@ describe("processLiquidConfidentialTransaction across declaration shapes", () => expect(recorded.mnemonicCalls).toBe(1); }); }); + +// AC-11 at the seam it actually protects: the guard reads the finished transaction's own +// bytes, so a module that spends something nobody asked for is caught even though every +// other part of the request was well formed. +describe("processLiquidConfidentialTransaction guards what it signs", () => { + function subjectSpending(extra: { txid: string; vout: number }) { + const recorded: Recorded = { broadcasts: [], mnemonicCalls: 0 }; + const dependency = dependencies(recorded); + + return { + method: createProcessLiquidConfidentialTransaction({ + ...dependency, + loadSmplx: async () => { + const module = (await dependency.loadSmplx()) as never as { + TransactionBuilder: new () => { spends: { txid: string; vout: number }[] }; + }; + + return { + ...module, + TransactionBuilder: class extends module.TransactionBuilder { + // Stands in for a module doing something it was not asked to. + free() {} + addOutput() { + this.spends.push(extra); + } + }, + } as never; + }, + }), + recorded, + }; + } + + test("refuses a transaction spending an input nobody asked for, naming it", async () => { + const { method } = subjectSpending({ txid: "9".repeat(64), vout: 2 }); + + await expect(method(params(), context())).rejects.toThrow(/9{64}:2/); + }); + + test("and nothing reaches the network", async () => { + const { method, recorded } = subjectSpending({ txid: "9".repeat(64), vout: 2 }); + + await expect(method(params({ broadcast: true }), context())).rejects.toThrow(); + expect(recorded.broadcasts).toHaveLength(0); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 5214d36..3b640ea 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -14,6 +14,7 @@ import { type ReadFeeRate, type ReadTxOut, } from "../../../domain/manifest/chainRead"; +import { guardSpentInputs } from "../../../domain/manifest/inputGuard"; import { isRefusal, type ManifestReview, @@ -186,6 +187,23 @@ export const createProcessLiquidConfidentialTransaction = ( }, ); + // What came back spends only what the action required and the wallet chose, or + // nothing is returned at all. The guard reads the transaction's own bytes rather + // than asking the module, because a module's account of itself cannot answer + // whether it did something it was not asked to. + const guarded = guardSpentInputs(signed.transactionHex, { + covenantInputs: review.covenantInputs.map(({ txid, vout }) => ({ txid, vout })), + walletInputs: review.selected.map(({ txid, vout }) => ({ txid, vout })), + }); + + if (!guarded.ok) { + throw new WalletRpcInvalidParamsError( + guarded.reason, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + if (!params.broadcast) { return { broadcast: false, ...signed }; } diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.test.ts new file mode 100644 index 0000000..05230fa --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; + +import { guardSpentInputs } from "./inputGuard"; +import { spentInputs } from "./spentInputs"; + +// AC-11. The expected set is known before the signing module runs — the covenant inputs the +// action requires and the wallet outputs the wallet chose — so what is checked is the bytes +// that would reach the network, not the module's own account of what it did. + +const A = "a".repeat(64); +const B = "b".repeat(64); +const C = "c".repeat(64); + +/** An Elements transaction serialised as far as its inputs, which is all this reads. */ +function transaction(...spends: { issuance?: boolean; txid: string; vout: number }[]): string { + const header = "02000000" + "01"; + const count = spends.length.toString(16).padStart(2, "0"); + const inputs = spends + .map(({ issuance, txid, vout }) => { + const reversed = (txid.match(/../g) ?? []).reverse().join(""); + const marked = issuance ? vout | 0x80_00_00_00 : vout; + const index = (marked >>> 0).toString(16).padStart(8, "0").match(/../g)!.reverse().join(""); + + return `${reversed}${index}00ffffffff`; + }) + .join(""); + + return `${header}${count}${inputs}`; +} + +describe("spentInputs", () => { + test("reads one outpoint out of the bytes", () => { + expect(spentInputs(transaction({ txid: A, vout: 1 }))).toEqual({ + ok: true, + spent: [{ txid: A, vout: 1 }], + }); + }); + + test("reads several, in order", () => { + const result = spentInputs(transaction({ txid: A, vout: 0 }, { txid: B, vout: 7 })); + + expect(result.ok && result.spent).toEqual([ + { txid: A, vout: 0 }, + { txid: B, vout: 7 }, + ]); + }); + + // Elements marks issuance in the top bits of the index rather than in a field of its own, + // so an index read without unmasking is a number no outpoint has. + test("unmasks the issuance flag out of the index", () => { + const result = spentInputs(transaction({ issuance: true, txid: A, vout: 2 })); + + expect(result.ok && result.spent).toEqual([{ txid: A, vout: 2 }]); + }); + + test("refuses bytes that are not hex", () => { + expect(spentInputs("zz").ok).toBe(false); + }); + + test("refuses a transaction that ends inside its inputs", () => { + expect(spentInputs("0200000001" + "02" + "aa".repeat(10)).ok).toBe(false); + }); +}); + +describe("guardSpentInputs", () => { + const covenant = { txid: A, vout: 0 }; + const wallet = { txid: B, vout: 1 }; + const expected = { covenantInputs: [covenant], walletInputs: [wallet] }; + + test("passes when the transaction spends exactly what was expected", () => { + expect(guardSpentInputs(transaction(covenant, wallet), expected)).toEqual({ ok: true }); + }); + + test("passes whatever order they appear in", () => { + expect(guardSpentInputs(transaction(wallet, covenant), expected)).toEqual({ ok: true }); + }); + + test("refuses an input the action did not require and the wallet did not choose", () => { + const result = guardSpentInputs(transaction(covenant, wallet, { txid: C, vout: 3 }), expected); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain(`${C}:3`); + }); + + // A transaction that spends less than the action requires is not a safer version of it. + test("refuses one that leaves a required input out", () => { + const result = guardSpentInputs(transaction(wallet), expected); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain(`${A}:0`); + }); + + test("refuses a transaction whose bytes it cannot read, rather than passing it", () => { + expect(guardSpentInputs("not hex", expected).ok).toBe(false); + }); + + test("passes an action with no covenant inputs at all", () => { + expect( + guardSpentInputs(transaction(wallet), { covenantInputs: [], walletInputs: [wallet] }), + ).toEqual({ ok: true }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.ts b/apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.ts new file mode 100644 index 0000000..b5a90b5 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.ts @@ -0,0 +1,65 @@ +import { type OutPoint, spentInputs } from "./spentInputs"; + +export type GuardResult = { ok: true } | { ok: false; reason: string }; + +/** + * Checks the finished transaction spends only what it was supposed to. + * + * The wallet knows the whole expected set before the signing module runs: the covenant + * inputs the action requires, which the runtime resolved and verified against the chain, and + * the wallet outputs the wallet itself selected, because coin selection happens on this side. + * Nothing else has any business being spent. + * + * The guard this replaces compared lwk's reported signatures before and after signing, which + * needed lwk to be the signer. On this path it is not — the module blinds, signs and + * finalises internally — so what is compared is which outpoints the finished transaction + * spends. The shape is the same and worth keeping: an expected set against an observed one, + * refusing on difference, rather than trusting that nothing went wrong. + * + * A missing input is a difference too. A transaction that spends less than the action + * requires is not a safer version of it; it is a different transaction, and the covenant it + * left out is one the person was shown. + */ +export function guardSpentInputs( + transactionHex: string, + expected: { covenantInputs: OutPoint[]; walletInputs: OutPoint[] }, +): GuardResult { + const observed = spentInputs(transactionHex); + + if (!observed.ok) { + return { ok: false, reason: observed.reason }; + } + + const permitted = new Set( + [...expected.covenantInputs, ...expected.walletInputs].map((outpoint) => key(outpoint)), + ); + const seen = new Set(observed.spent.map((outpoint) => key(outpoint))); + + for (const outpoint of observed.spent) { + if (!permitted.has(key(outpoint))) { + return { + ok: false, + reason: + `The signed transaction spends ${key(outpoint)}, which this action does not require ` + + "and the wallet did not choose. Nothing is returned.", + }; + } + } + + for (const outpoint of [...expected.covenantInputs, ...expected.walletInputs]) { + if (!seen.has(key(outpoint))) { + return { + ok: false, + reason: + `The signed transaction leaves out ${key(outpoint)}, which this action requires. ` + + "Nothing is returned.", + }; + } + } + + return { ok: true }; +} + +function key(outpoint: OutPoint): string { + return `${outpoint.txid}:${outpoint.vout}`; +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/spentInputs.ts b/apps/extension/src/core/chains/liquid/domain/manifest/spentInputs.ts new file mode 100644 index 0000000..f598d21 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/spentInputs.ts @@ -0,0 +1,144 @@ +/** One transaction input, as the outpoint it spends. */ +export type OutPoint = { txid: string; vout: number }; + +export type SpentInputs = { ok: true; spent: OutPoint[] } | { ok: false; reason: string }; + +/** + * The outpoints a finished transaction spends, read out of its own bytes. + * + * Deliberately parsed rather than asked for. This exists to catch a signing module spending + * something the wallet did not ask it to, and a module's own account of what it spent is the + * one source that cannot answer that question — it would be the same component reporting on + * itself. The bytes are what would reach the network. + * + * Only as far as the inputs, which sit near the front: version, the segwit marker, the input + * count, and then each input's outpoint, script and sequence. Everything after is somebody + * else's business. + */ +export function spentInputs(transactionHex: string): SpentInputs { + const bytes = decode(transactionHex); + + if (!bytes) { + return { ok: false, reason: "The signed transaction is not hex." }; + } + + const reader = { at: 0, bytes }; + + // Version, then the marker Elements uses to say whether witness data follows. + if (!skip(reader, 5)) { + return { ok: false, reason: "The signed transaction ends before its inputs." }; + } + + const count = readVarint(reader); + + if (count === undefined) { + return { ok: false, reason: "The signed transaction declares no input count." }; + } + + const spent: OutPoint[] = []; + + for (let index = 0n; index < count; index += 1n) { + const txid = readReversedHex(reader, 32); + const vout = readUint32(reader); + const scriptLength = readVarint(reader); + + if (txid === undefined || vout === undefined || scriptLength === undefined) { + return { ok: false, reason: `The signed transaction ends inside input ${index}.` }; + } + + if (!skip(reader, Number(scriptLength) + 4)) { + return { ok: false, reason: `The signed transaction ends inside input ${index}.` }; + } + + // Elements marks issuance and peg-in in the top two bits of the index rather than in + // a field of their own, so the index has to be unmasked before it means anything. + spent.push({ txid, vout: vout & 0x3f_ff_ff_ff }); + } + + return { ok: true, spent }; +} + +type Reader = { at: number; bytes: Uint8Array }; + +function decode(hex: string): Uint8Array | undefined { + const digits = hex.startsWith("0x") ? hex.slice(2) : hex; + + if (digits.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(digits)) { + return undefined; + } + + return Uint8Array.from(digits.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16)); +} + +function skip(reader: Reader, count: number): boolean { + if (reader.at + count > reader.bytes.length) { + return false; + } + + reader.at += count; + + return true; +} + +/** A txid is serialised in reverse of how it is written, which is why it is turned back. */ +function readReversedHex(reader: Reader, length: number): string | undefined { + if (reader.at + length > reader.bytes.length) { + return undefined; + } + + const slice = reader.bytes.slice(reader.at, reader.at + length); + + reader.at += length; + + return [...slice] + .reverse() + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function readUint32(reader: Reader): number | undefined { + if (reader.at + 4 > reader.bytes.length) { + return undefined; + } + + let value = 0; + + for (let offset = 3; offset >= 0; offset -= 1) { + value = value * 256 + (reader.bytes[reader.at + offset] ?? 0); + } + + reader.at += 4; + + return value; +} + +function readVarint(reader: Reader): bigint | undefined { + const first = reader.bytes[reader.at]; + + if (first === undefined) { + return undefined; + } + + reader.at += 1; + + const widths: Record<number, number> = { 0xfd: 2, 0xfe: 4, 0xff: 8 }; + const width = widths[first]; + + if (width === undefined) { + return BigInt(first); + } + + if (reader.at + width > reader.bytes.length) { + return undefined; + } + + let value = 0n; + + for (let offset = width - 1; offset >= 0; offset -= 1) { + value = value * 256n + BigInt(reader.bytes[reader.at + offset] ?? 0); + } + + reader.at += width; + + return value; +} From fb72f6da20126064552929353f42239cfdf9b682 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 16:26:37 +0300 Subject: [PATCH 045/124] feat(liquid): make a value carry where it came from, by type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person approving a contract action is really deciding how much of what they are shown is the site's word, and that question has an answer per value rather than per screen. So a value knows its origin, and cannot be separated from it by accident: the brand makes a plain value unassignable where a provenanced one is wanted, so a surface that renders only provenanced values cannot render an unprovenanced one. Not does not — cannot. Combining takes the weaker of two origins, which is what stops the site's word being laundered into the wallet's by arithmetic: a number computed from something the site asserted is something the site asserted, however much happened in between. Nothing can raise an origin; the only way to a stronger one is to establish the value again from a stronger source. The confirmation model is built where what the wallet established is known rather than at the surface, because a surface handed plain values would have to guess which of them were the site's word. --- .../processConfidentialTransaction/index.ts | 1 + .../liquid/domain/manifest/confirmation.ts | 124 ++++++++++++++++++ .../liquid/domain/manifest/provenance.test.ts | 98 ++++++++++++++ .../liquid/domain/manifest/provenance.ts | 95 ++++++++++++++ .../liquid/domain/manifest/review.test.ts | 75 +++++++++++ .../chains/liquid/domain/manifest/review.ts | 22 +++- 6 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/confirmation.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/provenance.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/provenance.ts diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 3b640ea..607ca2b 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -247,6 +247,7 @@ export const createProcessLiquidConfidentialTransaction = ( new smplx.Contract(source, argumentsJson).scriptPubKeyHex(network), fundingUtxos: context.walletBackend.getUtxos(account, account.rawPolicyAssetId), network, + accountLabel: `${account.chain?.id ?? context.chain.id} account ${account.accountGroupIndex}`, readFeeRate: dependencies.readFeeRate(context.chain), readTxOut: dependencies.readTxOut(context.chain), walletScriptPubKeyHex: await dependencies.scriptPubKeyHexOf( diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/confirmation.ts b/apps/extension/src/core/chains/liquid/domain/manifest/confirmation.ts new file mode 100644 index 0000000..c724e26 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/confirmation.ts @@ -0,0 +1,124 @@ +import { asRecord } from "./json"; +import type { NormalisedAction, NormalisedManifest } from "./normalise"; +import { computed, fromSite, type Provenanced, type Origin, verified } from "./provenance"; +import type { ManifestReview } from "./review"; + +/** One asset's movement in or out of the wallet, as a person would read it. */ +export type NetEffect = { + /** The asset moving, by the id the chain knows it as. */ + asset: Provenanced<string>; + /** Base units, negative when the wallet is paying out. */ + sats: Provenanced<bigint>; +}; + +/** One covenant the action touches, and what the wallet established about it. */ +export type CovenantRow = { + address: Provenanced<string>; + /** What kind of UTXO the protocol calls it. Its own word, so its own provenance. */ + utxoType: Provenanced<string>; + /** Whether the wallet compared this address against where the funds actually sit. */ + verified: Provenanced<boolean>; +}; + +/** + * Everything the person is shown before they decide, and where each part came from. + * + * Every field is provenanced, which is not decoration: a plain value cannot be assigned to + * one of these, so a value whose origin nobody established cannot reach this surface at all. + * That is the difference between a rule and a type. + * + * What the wallet established for itself and what the site merely said sit in the same + * object deliberately. Separating them into two screens would let a reader assume the first + * screen is all that matters; keeping them together with their origins attached is what + * makes the comparison possible. + */ +export type ConfirmationModel = { + /** Which account is acting. Implicit in the wallet's own selection, so it is shown. */ + account: Provenanced<string>; + /** The action's name, as the manifest spells it. */ + action: Provenanced<string>; + covenants: CovenantRow[]; + /** What the wallet worked out this will cost, which is not the figure that gets charged. */ + feeSats: Provenanced<bigint>; + /** The wallet's own balance change per asset — the authoritative figure. */ + netEffect: NetEffect[]; + /** The protocol's name, which is the site's word for itself. */ + protocol: Provenanced<string>; + /** The protocol's own one-line summary, when it wrote one. Site text, always. */ + summary?: Provenanced<string>; +}; + +/** + * Builds what the person sees from what the wallet established. + * + * The net effect is the wallet's own balance change rather than the manifest's declared + * amounts. A covenant input the wallet does not own is not part of that change and is not + * counted into it; those appear as their own rows, which is what the reference + * implementation does and for the same reason — the declared amounts are the site's account + * of the transaction, and the balance change is what actually happens to this person. + */ +export function confirmationModel( + review: ManifestReview, + manifest: NormalisedManifest, + action: NormalisedAction, + input: { accountLabel: string; policyAsset: string }, +): ConfirmationModel { + const paid = review.outputs.reduce((total, output) => total + output.sats, 0n); + const summary = actionSummary(action); + + return { + account: computed(input.accountLabel), + action: fromSite(review.action), + covenants: review.covenants.map((found) => ({ + address: + found.verified === "matches-chain" ? verified(found.address) : computed(found.address), + utxoType: fromSite(found.utxoType), + verified: computed(found.verified === "matches-chain"), + })), + // Computed rather than chain-read: it is the wallet's own estimate of a figure the + // network will settle, and calling it anything stronger would overstate it. + feeSats: computed(review.estimatedFeeSats), + netEffect: [ + { + asset: computed(input.policyAsset), + // Negative: everything the action pays out leaves this wallet, and the fee with it. + sats: computed(-(paid + review.estimatedFeeSats)), + }, + ], + protocol: fromSite(manifest.protocol ?? ""), + ...(summary === undefined ? {} : { summary: fromSite(summary) }), + }; +} + +/** + * What a person is told about a value's origin, in their own terms. + * + * The site's word is the one that has to be unmistakable, so it is the one stated plainly + * rather than left to a badge nobody reads. + */ +export function describeOrigin(origin: Origin): string { + switch (origin) { + case "chain": { + return "read from the network"; + } + + case "computed": { + return "worked out by this wallet"; + } + + case "site": { + return "claimed by the site"; + } + + default: { + return "checked by this wallet against the network"; + } + } +} + +/** The protocol's own one-line summary of the action, when its display metadata carries one. */ +function actionSummary(action: NormalisedAction): string | undefined { + const declared = asRecord(action.node.ui)?.action ?? action.node.description; + + return typeof declared === "string" ? declared : undefined; +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/provenance.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/provenance.test.ts new file mode 100644 index 0000000..7f2fad0 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/provenance.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +import { + combine, + computed, + fromChain, + fromSite, + isEstablished, + map, + type Provenanced, + verified, + weaker, +} from "./provenance"; + +// AC-07's mechanism. The rules are that site provenance never becomes wallet provenance, +// that combining takes the weakest input, and that an unprovenanced value cannot be +// rendered — the last of which is a type property rather than a test, and is asserted at +// the bottom by code that must not compile. + +describe("origins are ordered by how much the site could influence them", () => { + test("what the wallet checked against the network is the strongest", () => { + expect(weaker("verified", "chain")).toBe("chain"); + }); + + test("what the network says beats what the wallet worked out from site inputs", () => { + expect(weaker("chain", "computed")).toBe("computed"); + }); + + test("the site's word is the weakest there is", () => { + expect(weaker("computed", "site")).toBe("site"); + expect(weaker("verified", "site")).toBe("site"); + }); + + test("an origin combined with itself is itself", () => { + expect(weaker("chain", "chain")).toBe("chain"); + }); +}); + +describe("combining values", () => { + test("takes the weaker of the two origins", () => { + const total = combine(fromChain(2n), fromSite(3n), (left, right) => left + right); + + expect(total).toEqual({ origin: "site", value: 5n } as unknown as Provenanced<bigint>); + }); + + // This is the rule that matters: a number computed from something the site asserted is + // something the site asserted, however much arithmetic happened in between. + test("so arithmetic cannot launder the site's word into the wallet's", () => { + const laundered = combine(computed(1000n), fromSite(1n), (left, right) => left * right); + + expect(laundered.origin).toBe("site"); + }); + + test("two wallet values stay the wallet's", () => { + expect(combine(verified(1n), computed(2n), (a, b) => a + b).origin).toBe("computed"); + }); +}); + +describe("deriving from one value", () => { + test("keeps its origin", () => { + expect(map(fromSite("0x01"), (value) => value.toUpperCase()).origin).toBe("site"); + }); + + test("and cannot raise it, because formatting establishes nothing", () => { + expect(map(fromSite(1n), (value) => value + 1n).origin).toBe("site"); + }); +}); + +describe("what a person is deciding about", () => { + test("a wallet finding is established", () => { + expect(isEstablished(verified("tex1p"))).toBe(true); + expect(isEstablished(fromChain(42n))).toBe(true); + expect(isEstablished(computed(1n))).toBe(true); + }); + + test("the site's word is not", () => { + expect(isEstablished(fromSite("a lending protocol"))).toBe(false); + }); +}); + +// The mechanism itself: a plain value is not assignable where a provenanced one is wanted, +// so a surface that renders only provenanced values cannot render an unprovenanced one. +// Asserted as a compile-time fact, because that is the kind of fact it is. +describe("an unprovenanced value cannot reach a surface that wants one", () => { + test("a plain value is rejected by the type", () => { + const render = (shown: Provenanced<string>): string => shown.value; + + // @ts-expect-error a plain string carries no origin, so it cannot be rendered + expect(() => render("a bare string")).toBeDefined(); + }); + + test("and an object shaped like one is too, because the brand is not writable", () => { + const render = (shown: Provenanced<string>): string => shown.value; + + // @ts-expect-error the brand cannot be written by hand + expect(() => render({ origin: "verified", value: "forged" })).toBeDefined(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/provenance.ts b/apps/extension/src/core/chains/liquid/domain/manifest/provenance.ts new file mode 100644 index 0000000..996c04c --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/provenance.ts @@ -0,0 +1,95 @@ +/** + * Where a value came from, ordered by how much the requesting site could have influenced it. + * + * The order is the point. A person deciding whether to approve an action is really deciding + * how much of what they are shown is the site's word, and that question has an answer per + * value rather than per screen. + */ +export type Origin = + /** The wallet derived it and matched it against what the chain reports. Two sources agreed. */ + | "verified" + /** Read from the network. The site cannot influence what sits at an outpoint. */ + | "chain" + /** The wallet worked it out, from inputs that may have come from the site. */ + | "computed" + /** Asserted by the site and checked by nobody. */ + | "site"; + +/** Weakest last. Combining values takes the weakest, so this is the order that decides. */ +const STRENGTH: Origin[] = ["verified", "chain", "computed", "site"]; + +declare const PROVENANCE: unique symbol; + +/** + * A value that knows where it came from, and cannot be separated from it by accident. + * + * The brand is what makes the rule a type rather than a habit: a plain string cannot be + * passed where a provenanced one is wanted, so a surface that renders only provenanced + * values cannot render an unprovenanced one — not "does not", cannot. That is the whole + * mechanism. Everything else here is bookkeeping around it. + */ +export type Provenanced<T> = { + readonly [PROVENANCE]: true; + readonly origin: Origin; + readonly value: T; +}; + +/** The wallet derived this and matched it against the chain. */ +export function verified<T>(value: T): Provenanced<T> { + return brand(value, "verified"); +} + +/** Read from the network. */ +export function fromChain<T>(value: T): Provenanced<T> { + return brand(value, "chain"); +} + +/** The wallet worked this out. */ +export function computed<T>(value: T): Provenanced<T> { + return brand(value, "computed"); +} + +/** The site said so. */ +export function fromSite<T>(value: T): Provenanced<T> { + return brand(value, "site"); +} + +/** + * Derives a value from two others, at the weaker of their origins. + * + * A number computed from something the site asserted is something the site asserted, however + * much arithmetic happened in between. Taking the weakest is what stops provenance being + * laundered by a calculation. + */ +export function combine<A, B, T>( + left: Provenanced<A>, + right: Provenanced<B>, + derive: (left: A, right: B) => T, +): Provenanced<T> { + return brand(derive(left.value, right.value), weaker(left.origin, right.origin)); +} + +/** + * Derives a value from one other, keeping its origin. + * + * Formatting, rounding and renaming do not make a value more trustworthy than what it was + * derived from, so nothing here can raise an origin — the only way to a stronger one is to + * establish the value again from a stronger source. + */ +export function map<A, T>(source: Provenanced<A>, derive: (value: A) => T): Provenanced<T> { + return brand(derive(source.value), source.origin); +} + +/** The weaker of two origins. */ +export function weaker(left: Origin, right: Origin): Origin { + return STRENGTH.indexOf(left) >= STRENGTH.indexOf(right) ? left : right; +} + +/** Whether this value is the wallet's own finding rather than the site's word. */ +export function isEstablished<T>(value: Provenanced<T>): boolean { + return value.origin !== "site"; +} + +function brand<T>(value: T, origin: Origin): Provenanced<T> { + return { origin, value } as Provenanced<T>; +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts index 0cc5522..d51e59e 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -35,6 +35,7 @@ const fundingUtxos = [ /** The three dependencies every case shares; individual tests override what they exercise. */ const deps = { + accountLabel: "liquid:testnet account 0", compile, compilerVersion: "0.6.0", policyAsset: "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", @@ -412,3 +413,77 @@ describe("an amount that depends on the fee", () => { } }); }); + +// AC-06 and AC-07 at the seam. What the person is shown is built where what the wallet +// established is known, so nothing downstream has to guess which values were the site's word. +describe("what the person is shown", () => { + const shown = async () => { + const result = await reviewManifestAction(request(), { + ...deps, + readTxOut: readTxOut("unused"), + }); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + return result.confirmation; + }; + + // AC-06: the four wallet-established facts. + test("names which account is acting, because the wallet chose it implicitly", async () => { + expect((await shown()).account).toMatchObject({ + origin: "computed", + value: "liquid:testnet account 0", + }); + }); + + test("shows what the wallet worked the fee out to be", async () => { + const model = await shown(); + + expect(model.feeSats.value > 0n).toBe(true); + expect(model.feeSats.origin).toBe("computed"); + }); + + test("shows the net effect on this wallet, as an outgoing figure", async () => { + const [effect] = (await shown()).netEffect; + + expect(effect?.sats.value).toBeLessThan(0n); + expect(effect?.asset.origin).toBe("computed"); + }); + + test("says whether the wallet checked each covenant against the network", async () => { + const [covenant] = (await shown()).covenants; + + expect(covenant?.verified).toMatchObject({ origin: "computed", value: false }); + }); + + test("and marks a covenant it did check as checked", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + ...deps, + readTxOut: readTxOut(DERIVED), + }); + + if (!isRefusal(result)) { + expect(result.confirmation.covenants[0]?.verified.value).toBe(true); + expect(result.confirmation.covenants[0]?.address.origin).toBe("verified"); + } + }); + + // AC-07: the site's text is the site's, and says so. + test("attributes the protocol's name to the site", async () => { + expect((await shown()).protocol.origin).toBe("site"); + }); + + test("attributes the action's name to the site", async () => { + expect((await shown()).action.origin).toBe("site"); + }); + + test("attributes the protocol's own summary to the site", async () => { + expect((await shown()).summary?.origin).toBe("site"); + }); + + test("the utxo type is the protocol's own word for it, and is marked so", async () => { + expect((await shown()).covenants[0]?.utxoType.origin).toBe("site"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts index c10219b..e7a05a3 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -1,6 +1,7 @@ import { encodeExplicitTxOut, type ReadFeeRate, type ReadTxOut } from "./chainRead"; import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; import { resolveComputedParams } from "./computed"; +import { type ConfirmationModel, confirmationModel } from "./confirmation"; import { type CompileCovenant, covenantMatchesChain, deriveCovenantAddress } from "./covenant"; import { type CompileScriptPubKey, covenantHashFrom } from "./covenantHash"; import { estimateFeeSats } from "./fee"; @@ -95,6 +96,14 @@ export type ManifestReview = { * silently is indistinguishable from one that missed it. */ ignoredConstructs: ConstructFinding[]; + /** + * Everything the person is shown, with every value's origin attached. + * + * Built here rather than at the surface because this is where what the wallet established + * is known — a surface handed plain values would have to guess which of them were the + * site's word, and guessing is the failure the provenance exists to prevent. + */ + confirmation: ConfirmationModel; /** Legacy spellings the document used, so the generation it came from can be reported. */ normalisation: NormalisationNote[]; outputs: ReviewedOutput[]; @@ -134,6 +143,8 @@ export async function reviewManifestAction( readTxOut: ReadTxOut; /** The SimplicityHL version compiled into this wallet, which is the only one it has. */ compilerVersion: string; + /** How this account is named to the person, since the wallet chose it implicitly. */ + accountLabel: string; /** The asset this wallet pays fees in and is the only one it moves. */ policyAsset: string; /** Compiles a contract to the scriptPubKey it locks to, for the hashes a manifest computes. */ @@ -371,8 +382,9 @@ export async function reviewManifestAction( return { reason: selection.reason, refused: true }; } - return { + const review: ManifestReview = { action: request.action, + confirmation: {} as ConfirmationModel, covenantInputs, covenants, estimatedFeeSats: estimatedFee, @@ -383,6 +395,14 @@ export async function reviewManifestAction( protocol: manifest.protocol ?? "", selected: selection.selected, }; + + return { + ...review, + confirmation: confirmationModel(review, manifest, action, { + accountLabel: input.accountLabel, + policyAsset: input.policyAsset, + }), + }; } /** Confirmation target for the fee estimate, in blocks. */ From 820899dd16360464bdce5426db477a90c67b3b32 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 16:32:57 +0300 Subject: [PATCH 046/124] test(liquid): keep the runtime a function of what it is given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person who wipes the wallet and restores from the recovery phrase has to be able to perform the same action, and that is easy to satisfy accidentally and easy to break silently — a cache added in a later slice for a good reason becomes load-bearing without anyone deciding it should. So the check is structural and standing rather than a demonstration run once: no module reaches for browser storage, none imports anything outside the runtime but one error type, nothing outside a function can be reassigned, and no module-level table is ever written to. A cache has to break one of those to exist. Beside it, the method twice on contexts that share nothing reaches the same transaction, and what it reads from the wallet is exactly the four things a restored wallet derives by scanning from the phrase. --- .../index.test.ts | 48 ++++++++++ .../liquid/domain/manifest/stateless.test.ts | 95 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/stateless.test.ts diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 93b1339..dc1acc5 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -267,3 +267,51 @@ describe("processLiquidConfidentialTransaction guards what it signs", () => { expect(recorded.broadcasts).toHaveLength(0); }); }); + +// AC-14 and D7: a person who wipes the wallet and restores from the recovery phrase must be +// able to perform the same action. There is nothing to restore *to* — so what is shown is +// that the method is a function of the request, the phrase and the chain, and that a second +// run on a context built from nothing else reaches the same transaction. +describe("processLiquidConfidentialTransaction on a restored wallet", () => { + test("the same request twice, on contexts sharing nothing, reaches the same transaction", async () => { + const first = await subject().method(params(), context()); + const restored = await subject().method(params(), context()); + + expect(restored).toEqual(first); + }); + + test("and reaches the same transaction whether or not one ran before it", async () => { + const alone = await subject().method(params(), context()); + const { method } = subject(); + + await method(params({ broadcast: true }), context()); + + expect(await method(params(), context())).toEqual(alone); + }); + + // What it reads from the wallet is the point: the account, its own outputs and an address, + // all of which a restored wallet derives from the phrase by scanning. Anything else would + // be something a previous run left behind. + test("reads nothing from the wallet a restored one could not derive", async () => { + const read: string[] = []; + const base = context(); + const watched = new Proxy(base, { + get(target, property) { + if (typeof property === "string") { + read.push(property); + } + + return target[property as keyof typeof target]; + }, + }); + + await subject().method(params(), watched); + + expect([...new Set(read)].sort()).toEqual([ + "authorization", + "chain", + "keyManagerState", + "walletBackend", + ]); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/stateless.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/stateless.test.ts new file mode 100644 index 0000000..e1bb53a --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/stateless.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// AC-14 and D7: a person who wipes the wallet, the browser or the machine must be able to +// perform the same action from the same site with no loss of capability. That is easy to +// satisfy accidentally and easy to break silently — a cache added in a later slice for a +// good reason becomes load-bearing without anyone deciding it should. So the check is +// structural and standing rather than a demonstration run once. + +const RUNTIME = dirname(fileURLToPath(import.meta.url)); + +function sources(): { name: string; text: string }[] { + return readdirSync(RUNTIME) + .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) + .map((name) => ({ name, text: readFileSync(join(RUNTIME, name), "utf8") })); +} + +describe("the runtime reads nothing it remembered", () => { + // Everything the runtime needs arrives in the request or derives from the recovery + // phrase. Anything that reads what a previous run wrote down would make a second run on + // a restored wallet behave differently from the first, which is the whole failure. + const persistence = [ + "localStorage", + "sessionStorage", + "indexedDB", + "chrome.storage", + "browser.storage", + "webextension-polyfill", + ]; + + for (const api of persistence) { + test(`no module reaches for ${api}`, () => { + const offenders = sources() + .filter(({ text }) => text.includes(api)) + .map(({ name }) => name); + + expect(offenders).toEqual([]); + }); + } + + test("and none imports anything outside this runtime and its own dependencies", () => { + const allowed = /^(?:\.\/[a-zA-Z]+|@noble\/hashes\/[a-z0-9.]+|zod)$/; + const offenders: string[] = []; + + for (const { name, text } of sources()) { + for (const match of text.matchAll(/from "(?<specifier>[^"]+)"/g)) { + const specifier = match.groups?.specifier ?? ""; + + if (!allowed.test(specifier)) { + offenders.push(`${name} → ${specifier}`); + } + } + } + + // One import reaches outside: the wallet RPC error the request validator throws. It + // carries no state and reads nothing, and is listed rather than excluded by pattern so + // that a second one has to be added here deliberately. + expect(offenders).toEqual(["validation.ts → @/core/wallet-rpc/errors"]); + }); +}); + +// The other half of D7: the runtime is a function of its inputs. Given the same request and +// the same chain answers it produces the same transaction, whatever happened before — which +// is what "restore from the phrase and do it again" means when there is nothing to restore. +describe("the same request twice produces the same result", () => { + test("nothing outside a function can be reassigned", () => { + const offenders = sources() + .filter(({ text }) => /^(?:let|var) /m.test(text)) + .map(({ name }) => name); + + expect(offenders).toEqual([]); + }); + + // A `Set` or `Map` at module level is a lookup table or a cache, and the difference is + // whether anything writes to it. The tables here are built from literals and only read; + // a write to one is where a cache would begin. + test("no module-level collection is ever written to", () => { + const offenders: string[] = []; + + for (const { name, text } of sources()) { + for (const match of text.matchAll(/^const (?<held>\w+) = new (?:Map|Set|WeakMap)\(/gm)) { + const held = match.groups?.held ?? ""; + const written = new RegExp(`\\b${held}\\.(?:add|set|delete|clear)\\(`); + + if (written.test(text)) { + offenders.push(`${name} → ${held}`); + } + } + } + + expect(offenders).toEqual([]); + }); +}); From 64e9aebb337c5b83a7995b20086a95c1c24f385c Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 16:37:47 +0300 Subject: [PATCH 047/124] test(liquid): measure this wallet against every published protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven example manifests, vendored byte for byte rather than fetched: a test that reaches the network fails for reasons unrelated to the code, and a corpus that can change under a pinned expectation is not a fixture. They answer the question nothing written to suit the runtime can. Three generations of the lending protocol coexist, five of the seven declare their actions as class methods, lending and lending_v2 carry both shapes in one document, and zeroconf is a valid manifest that declares nothing at all — which a runtime assuming otherwise falls over on. The measurement is the deliverable: two of the seven build today and five refuse, each on the first construct this wallet does not read. The whole unread list is asserted so it cannot shrink or grow without someone changing it on purpose. --- .../domain/manifest/__fixtures__/README.md | 21 + .../manifest/__fixtures__/dex.manifest.json | 476 +++++ .../__fixtures__/last_will.manifest.json | 257 +++ .../__fixtures__/lending.manifest.json | 1575 +++++++++++++++++ .../__fixtures__/lending_v2.manifest.json | 1439 +++++++++++++++ .../__fixtures__/lending_v3.manifest.json | 1518 ++++++++++++++++ .../__fixtures__/zeroconf.manifest.json | 7 + .../liquid/domain/manifest/corpus.test.ts | 210 +++ 8 files changed, 5503 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/README.md create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/dex.manifest.json create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/last_will.manifest.json create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending.manifest.json create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v2.manifest.json create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v3.manifest.json create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/zeroconf.manifest.json create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/README.md b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/README.md new file mode 100644 index 0000000..2901fcb --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/README.md @@ -0,0 +1,21 @@ +# The published txManifest corpus + +The seven example manifests from `stringhandler/txmanifest-wallet` at +`7d56516a1a1e44a586f25d45a34619c3953758dd`, vendored byte for byte. They are here +rather than fetched because a test that reaches the network is a test that fails +for reasons unrelated to the code, and because a corpus that can change under a +pinned expectation is not a fixture. + +Three generations of the lending protocol coexist in it, which is the point: the +format changed faster than its own specification, and a runtime that only reads +the newest generation reads two of these three wrong. + +`p2pk-grouped.manifest.json` is not published. It is the p2pk protocol rewritten in +the older spellings — grouped classes and `compose_version` — because no legacy +twin of a published manifest exists to compare against, and the two declaration +shapes have to be shown to converge somewhere. + +The contract sources these manifests reference — `./lending.simf`, +`./asset_auth.simf`, `./issuance_factory.simf`, `./script_auth.simf`, +`./asset_auth_vault.simf` — are not published alongside them. In production they +arrive with the request. diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/dex.manifest.json b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/dex.manifest.json new file mode 100644 index 0000000..9f68469 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/dex.manifest.json @@ -0,0 +1,476 @@ +{ + "$comment": "Ported from Mosaik's tessera.simf (github.com/kaleidoswap/mosaik, crates/tessera/contracts/tessera.simf). Upstream substitutes the four offer terms as inline TESSERA_PARAM literals; here they are ordinary compile params. Change tessera.simf and every offer address changes — the terms live in the tapleaf. NOTE: the Refund method is not executable until upnext/12 (absolute nLockTime) lands; see its description.", + "manifest_version": "0.1.0", + "attestation_version": "1", + "protocol": "tessera-dex", + "description": "Tessera — a keyless atomic swap offer on Liquid, the primitive a Mosaik DEX is built from. One UTXO is one all-or-nothing offer: it holds asset A and is spendable by ANYONE who pays AMOUNT_B of ASSET_B to the maker (Settle), or, after TIMEOUT, by anyone who returns asset A to the maker (Refund). No signature on either path — the covenant is pure transaction introspection, and the maker is identified only by a scriptPubKey hash. Modelled as a class: one instance per offer.", + "chain": "liquid", + "utxo_types": { + "tessera_offer": { + "description": "The offer UTXO: holds OFFER_AMOUNT of the maker's asset A, spendable via the keyless Settle or Refund paths. Its address commits to the four offer terms (ASSET_B, AMOUNT_B, MAKER_SPK, TIMEOUT) plus MAX_FEE — so the terms cannot change once funded. It does NOT commit to asset A or its amount: the covenant never inspects them on the settle path (see the note in tessera.simf), which is why OFFER_ASSET_ID/OFFER_AMOUNT are instance fields but not compile params.", + "script": { + "type": "simplicity", + "source": "./tessera.simf", + "compile_params": { + "ASSET_B": "ASSET_B", + "AMOUNT_B": "AMOUNT_B", + "MAKER_SPK": "MAKER_SPK", + "TIMEOUT": "TIMEOUT", + "MAX_FEE": "MAX_FEE" + } + }, + "asset": "OFFER_ASSET_ID", + "confidential": false + }, + "maker_payout": { + "description": "The maker's payout address — a p2pk covenant over MAKER_PUB_KEY (see maker_payout.simf for why the payout leg is a covenant and not a plain wallet address). MAKER_SPK is the sha256 of this address's scriptPubKey. Asset-agnostic: it holds ASSET_B after a Settle, or asset A after a Refund, since the program only checks a signature. The 'asset' below is the Settle case, and the Refund output overrides it.", + "script": { + "type": "simplicity", + "source": "./maker_payout.simf", + "compile_params": { + "PUB_KEY": "MAKER_PUB_KEY" + } + }, + "asset": "ASSET_B", + "confidential": false + } + }, + "classes": { + "tessera_offer_contract": { + "description": "One Tessera swap offer. Created by MakeOffer, then consumed by exactly one of Settle (a taker fills it) or Refund (it expired). ClaimPayout is the maker's follow-up sweep of their proceeds.", + "fields": { + "OFFER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset A — what the maker is selling, locked in the offer UTXO." + }, + "OFFER_AMOUNT": { + "type": "u64", + "description": "How much of asset A is locked in the offer." + }, + "ASSET_B": { + "type": "liquid.asset_id", + "description": "Asset B — what the maker wants in return." + }, + "AMOUNT_B": { + "type": "u64", + "description": "Exactly how much of asset B the maker must be paid to settle." + }, + "MAKER_PUB_KEY": { + "type": "pubkey", + "description": "The maker's x-only key. Not used by the swap covenant — it defines the payout address, and later authorises ClaimPayout." + }, + "MAKER_SPK": { + "type": "bytes32", + "description": "sha256 of the maker payout scriptPubKey. This, not a key, is what the covenant enforces payment to. Computed by MakeOffer." + }, + "TIMEOUT": { + "type": "u32", + "description": "Absolute block height at which the offer becomes refundable." + }, + "MAX_FEE": { + "type": "u64", + "description": "Most a refund sweep may skim from the returned asset to pay the network fee." + } + }, + "methods": { + "MakeOffer": { + "is_constructor": true, + "description": "Publish an offer: lock asset A into the Tessera covenant and write the instance file recording the terms. From here the offer is live and anyone may fill it — no further action from the maker.", + "ui": { + "action": "publish a swap offer: {params.OFFER_AMOUNT} of {params.OFFER_ASSET_ID:symbol} for {params.AMOUNT_B} of {params.ASSET_B:symbol}" + }, + "params": { + "OFFER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset A — the asset id you are selling." + }, + "OFFER_AMOUNT": { + "type": "u64", + "description": "How much of asset A to lock in the offer." + }, + "ASSET_B": { + "type": "liquid.asset_id", + "description": "Asset B — the asset id you want to be paid in." + }, + "AMOUNT_B": { + "type": "u64", + "description": "Exactly how much of asset B you must receive. A taker pays this or the covenant rejects the spend — there are no partial fills." + }, + "MAKER_PUB_KEY": { + "type": "pubkey", + "description": "Your x-only public key — it defines where takers must pay you. Must be one of your own wallet's keys so you can later sweep the proceeds with ClaimPayout." + }, + "TIMEOUT": { + "type": "u32", + "description": "Absolute block height after which the offer may be refunded back to you. Pick a height comfortably in the future — until it passes, the only way out of the offer is for someone to fill it." + }, + "MAX_FEE": { + "type": "u64", + "default": "5000", + "description": "Most a refund sweep may skim from your returned asset to pay the network fee. Since anyone may sweep, this bounds what a griefer can burn." + } + }, + "inputs": [ + { + "id": "offer_asset_in", + "description": "Wallet UTXO providing asset A, sized >= OFFER_AMOUNT.", + "utxo_source": "wallet", + "asset": "params.OFFER_ASSET_ID", + "amount_sat": { + "min_amount": "params.OFFER_AMOUNT" + }, + "ui": { + "label": "the asset you are selling", + "role": "offer_asset" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "offer_out", + "description": "The funded offer — a live Tessera covenant UTXO holding asset A.", + "destination": { + "utxo_type": "tessera_offer" + }, + "asset": "params.OFFER_ASSET_ID", + "amount_sat": "params.OFFER_AMOUNT", + "confidential": false, + "ui": { + "label": "asset locked into the swap offer", + "role": "covenant" + } + }, + { + "id": "offer_change", + "description": "Asset A change back to the maker's wallet.", + "destination": "change", + "asset": "params.OFFER_ASSET_ID", + "optional": true, + "ui": { + "label": "unsold remainder returned to you", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ], + "validations": [ + { + "id": "offer_amount_nonzero", + "rule": { + "type": "arithmetic", + "expr": "params.OFFER_AMOUNT > 0" + }, + "error": { + "code": "INVALID_AMOUNT", + "message": "Offer amount must be greater than zero" + } + }, + { + "id": "price_nonzero", + "rule": { + "type": "arithmetic", + "expr": "params.AMOUNT_B > 0" + }, + "error": { + "code": "INVALID_PRICE", + "message": "Asked amount must be greater than zero" + } + }, + { + "id": "assets_differ", + "rule": { + "type": "arithmetic", + "expr": "params.OFFER_ASSET_ID != params.ASSET_B" + }, + "error": { + "code": "INVALID_PAIR", + "message": "Cannot swap an asset for itself" + } + } + ], + "create_instance": { + "class": "tessera_offer_contract", + "fields": { + "OFFER_ASSET_ID": "$params.OFFER_ASSET_ID", + "OFFER_AMOUNT": "$params.OFFER_AMOUNT", + "ASSET_B": "$params.ASSET_B", + "AMOUNT_B": "$params.AMOUNT_B", + "MAKER_PUB_KEY": "$params.MAKER_PUB_KEY", + "TIMEOUT": "$params.TIMEOUT", + "MAX_FEE": "$params.MAX_FEE", + "MAKER_SPK": { + "compute": "tapleaf", + "simf": "./maker_payout.simf", + "params": { + "PUB_KEY": { + "type": "pubkey", + "value": "MAKER_PUB_KEY" + } + } + } + } + } + }, + "Settle": { + "description": "Fill the offer: pay the maker AMOUNT_B of asset B and take asset A. Permissionless — anyone holding asset B can run this, the covenant carries no signature and does not care who you are. It only checks that output 0 pays exactly AMOUNT_B of ASSET_B to MAKER_SPK.", + "ui": { + "action": "fill the offer: pay {instance.AMOUNT_B} of {instance.ASSET_B:symbol} and take {instance.OFFER_AMOUNT} of {instance.OFFER_ASSET_ID:symbol}" + }, + "inputs": [ + { + "id": "offer_in", + "description": "The Tessera offer UTXO being filled, located via the state file. amount_sat is declared because covenant inputs are never chain-fetched: the engine takes an input's amount from --inputs-file, then the state file, then this spec — and silently falls back to 0 if all three are absent. MakeOffer funds the offer with exactly OFFER_AMOUNT, so the instance always knows it.", + "utxo_source": { + "utxo_type": "tessera_offer" + }, + "asset": "instance.OFFER_ASSET_ID", + "amount_sat": "instance.OFFER_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<u32, u32>", + "value": "Left(0)", + "description": "Settle path (PATH::LEFT). The u32 payload is the index of the output paying the maker — output 0 below. Output index is set by declaration order, so maker_payout_out must stay first." + } + }, + "ui": { + "label": "the swap offer being filled", + "role": "covenant" + } + }, + { + "id": "payment_in", + "description": "Taker's wallet UTXO providing asset B, sized >= AMOUNT_B.", + "utxo_source": "wallet", + "asset": "instance.ASSET_B", + "amount_sat": { + "min_amount": "instance.AMOUNT_B" + }, + "ui": { + "label": "your payment to the maker", + "role": "payment" + } + }, + { + "id": "fee_input", + "description": "Taker's wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "maker_payout_out", + "description": "MUST be output 0 — the leg the covenant checks, and the index committed to by the PATH witness above. Exactly AMOUNT_B of asset B to the maker's payout address; anything else and the spend is rejected.", + "destination": { + "utxo_type": "maker_payout" + }, + "asset": "instance.ASSET_B", + "amount_sat": "instance.AMOUNT_B", + "required_index": 0, + "confidential": false, + "ui": { + "label": "payment to the maker", + "role": "maker_payout" + } + }, + { + "id": "taker_out", + "description": "Asset A released from the offer to the taker — the whole locked amount, since Tessera has no partial fills.", + "destination": "wallet", + "asset": "instance.OFFER_ASSET_ID", + "amount_sat": "offer_in.amount_sat", + "ui": { + "label": "asset you bought from the offer", + "role": "settlement" + } + }, + { + "id": "payment_change", + "description": "Asset B change back to the taker's wallet.", + "destination": "change", + "asset": "instance.ASSET_B", + "optional": true, + "ui": { + "label": "payment change returned to you", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "Refund": { + "description": "Sweep an expired offer back to the maker. Permissionless and keyless like Settle: after TIMEOUT anyone may run this, but the covenant forces the asset back to MAKER_SPK, so a stranger's only power is to pay the fee on the maker's behalf (or, at worst, burn MAX_FEE). NOT EXECUTABLE YET: the refund path calls jet::check_lock_height(TIMEOUT), an absolute CLTV, and the engine cannot set a transaction nLockTime — see meta/tasks/upnext/12-engine-absolute-locktime.md. Same limitation as the lending example's liquidation path. Modelled here so the covenant's second path is documented and ready once task 12 lands; the sequence below already enables locktime.", + "ui": { + "action": "refund the expired offer: return {instance.OFFER_AMOUNT} of {instance.OFFER_ASSET_ID:symbol} to the maker" + }, + "inputs": [ + { + "id": "offer_in", + "description": "The expired Tessera offer UTXO, located via the state file. The sequence below is ENABLE_LOCKTIME_NO_RBF (0xFFFFFFFE): anything other than 0xFFFFFFFF enables the transaction nLockTime that check_lock_height reads. The engine warns that this has the BIP68 disable bit set — that is expected and correct here, since Tessera's timeout is an absolute height, not a relative one.", + "utxo_source": { + "utxo_type": "tessera_offer" + }, + "asset": "instance.OFFER_ASSET_ID", + "amount_sat": "instance.OFFER_AMOUNT", + "sequence": 4294967294, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<u32, u32>", + "value": "Right(0)", + "description": "Refund path (PATH::RIGHT). The u32 payload is the index of the output returning the asset to the maker — output 0 below." + } + }, + "ui": { + "label": "the expired swap offer", + "role": "covenant" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee. Paying the fee from a separate input (rather than skimming the locked asset) means the refund output returns the full locked amount, which satisfies the covenant's `out + MAX_FEE >= in` check with nothing skimmed.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "maker_refund_out", + "description": "MUST be output 0 — the index committed to by the PATH witness. The full locked asset A back to the maker's payout address. Note the asset override: this is asset A, not the maker_payout type's default ASSET_B.", + "destination": { + "utxo_type": "maker_payout" + }, + "asset": "instance.OFFER_ASSET_ID", + "amount_sat": "offer_in.amount_sat", + "confidential": false, + "ui": { + "label": "unsold asset returned to the maker", + "role": "maker_payout" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "ClaimPayout": { + "description": "The maker sweeps their proceeds out of the payout covenant into their wallet. Run this after a taker settles the offer. Unlike every other method here, this one is a plain signature check — the swap is already over.", + "ui": { + "action": "sweep your swap proceeds into your wallet" + }, + "inputs": [ + { + "id": "payout_in", + "description": "The maker_payout covenant UTXO holding the proceeds. With --state (the normal flow, since Settle writes one) the amount and asset are read straight off the UTXO record and the values below are ignored entirely. They exist only as the fallback for pinning the outpoint with --input and no state file, and they describe the post-Settle claim: the payout holds exactly AMOUNT_B of ASSET_B. After a Refund it holds asset A instead — that claim needs --state, which is fine, since Refund is not executable yet anyway.", + "utxo_source": { + "utxo_type": "maker_payout" + }, + "asset": "instance.ASSET_B", + "amount_sat": "instance.AMOUNT_B", + "witnesses": { + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "instance.MAKER_PUB_KEY" + }, + "description": "BIP340 Schnorr signature over the whole transaction, from the maker's key." + } + }, + "ui": { + "label": "your swap proceeds", + "role": "maker_payout" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "claimed_out", + "description": "The swept proceeds, sent to the maker's wallet. Reads the amount off payout_in, so whatever actually landed in the payout address is what gets swept.", + "destination": "wallet", + "asset": "payout_in.asset", + "amount_sat": "payout_in.amount_sat", + "ui": { + "label": "proceeds swept to your wallet", + "role": "settlement" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/last_will.manifest.json b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/last_will.manifest.json new file mode 100644 index 0000000..a0fc20a --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/last_will.manifest.json @@ -0,0 +1,257 @@ +{ + "manifest_version": "0.1.0", + "attestation_version": "1", + "protocol": "last-will", + "description": "Last Will — a recursive covenant with three spending paths: inherit (after a 180-day timelock), cold-key break-out, and hot-key refresh. Modelled as a class: one instance per will.", + "chain": "liquid", + "utxo_types": { + "last_will": { + "description": "Funds locked under the last-will covenant.", + "script": { + "type": "simplicity", + "source": "./last_will.simf", + "compile_params": { + "INHERITOR_PUB_KEY": "INHERITOR_PUB_KEY", + "HOT_PUB_KEY": "HOT_PUB_KEY", + "COLD_PUB_KEY": "COLD_PUB_KEY", + "INHERIT_BLOCKS": "INHERIT_BLOCKS" + } + }, + "asset": "lbtc", + "confidential": false + } + }, + "classes": { + "last_will_contract": { + "description": "A last-will covenant. One instance per will: created by Fund, then spent via ColdBreak, Refresh, or Inherit.", + "fields": { + "INHERITOR_PUB_KEY": { + "type": "pubkey" + }, + "HOT_PUB_KEY": { + "type": "pubkey" + }, + "COLD_PUB_KEY": { + "type": "pubkey" + }, + "INHERIT_BLOCKS": { + "type": "u16" + } + }, + "methods": { + "Fund": { + "is_constructor": true, + "description": "Set up the will: lock funds into the covenant and write the instance file recording the three keys.", + "params": { + "INHERITOR_PUB_KEY": { + "type": "pubkey", + "description": "The heir's x-only public key. They can claim the funds 180 days after the last move." + }, + "HOT_PUB_KEY": { + "type": "pubkey", + "description": "Owner's hot key. Auto-filled from your wallet signing key." + }, + "COLD_PUB_KEY": { + "type": "pubkey", + "description": "Owner's cold key. Your wallet's oracle key — the covenant escape hatch." + }, + "INHERIT_BLOCKS": { + "type": "u16", + "default": "25920", + "description": "Blocks of inactivity before the heir may claim. ~180 days ≈ 25920 (1-minute Liquid blocks). Max 65535." + }, + "amount_sat": { + "type": "u64", + "description": "Amount in satoshis to place under the will." + } + }, + "inputs": [ + { + "id": "funding_input", + "description": "Wallet UTXO providing the funds.", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { + "min_amount": "params.amount_sat" + } + } + ], + "outputs": [ + { + "id": "will_out", + "description": "The funded last-will output.", + "destination": { + "utxo_type": "last_will" + }, + "amount_sat": "params.amount_sat", + "asset": "lbtc" + }, + { + "id": "change_out", + "description": "Change returned to the funding wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "amount_nonzero", + "rule": { + "type": "arithmetic", + "expr": "params.amount_sat > 0" + }, + "error": { + "code": "INVALID_AMOUNT", + "message": "Amount must be greater than zero" + } + } + ], + "create_instance": { + "class": "last_will_contract", + "fields": { + "INHERITOR_PUB_KEY": "$params.INHERITOR_PUB_KEY", + "HOT_PUB_KEY": "$params.HOT_PUB_KEY", + "COLD_PUB_KEY": "$params.COLD_PUB_KEY", + "INHERIT_BLOCKS": "$params.INHERIT_BLOCKS" + } + } + }, + "ColdBreak": { + "description": "Owner breaks out of the covenant with the cold key. Funds return to the wallet and the covenant ends.", + "inputs": [ + { + "id": "will_in", + "description": "The last-will covenant UTXO, located via the state file.", + "utxo_source": { + "utxo_type": "last_will" + }, + "witnesses": { + "SPEND_PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<(), Either<(), ()>>", + "value": "Right(Left(()))", + "description": "Selects the cold-spend path." + }, + "COLD_SIG": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "compile_params.COLD_PUB_KEY" + }, + "description": "BIP340 signature from the cold key." + } + } + } + ], + "outputs": [ + { + "id": "to_wallet", + "description": "The reclaimed funds, sent to your wallet.", + "destination": "wallet", + "asset": "lbtc", + "amount_sat": "will_in.amount_sat - fee" + } + ] + }, + "Refresh": { + "description": "Owner moves the funds with the hot key, repeating the covenant. Output 0 must be the same last-will covenant; output 1 is the explicit fee.", + "inputs": [ + { + "id": "will_in", + "description": "The last-will covenant UTXO, located via the state file.", + "utxo_source": { + "utxo_type": "last_will" + }, + "witnesses": { + "SPEND_PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<(), Either<(), ()>>", + "value": "Right(Right(()))", + "description": "Selects the hot-key refresh path." + }, + "HOT_SIG": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "compile_params.HOT_PUB_KEY" + }, + "description": "BIP340 signature from the hot key." + } + } + } + ], + "outputs": [ + { + "id": "will_again", + "description": "The re-locked last-will output (same covenant address). Must be output 0.", + "destination": { + "utxo_type": "last_will" + }, + "asset": "lbtc", + "amount_sat": "will_in.amount_sat - fee", + "required_index": 0 + } + ] + }, + "Inherit": { + "description": "The heir claims the funds 180 days after the last move. Requires the inheritor's signature and a relative timelock.", + "inputs": [ + { + "id": "will_in", + "description": "The last-will covenant UTXO, located via the state file.", + "utxo_source": { + "utxo_type": "last_will" + }, + "sequence": { + "relative_blocks": "compile_params.INHERIT_BLOCKS" + }, + "witnesses": { + "SPEND_PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<(), Either<(), ()>>", + "value": "Left(())", + "description": "Selects the inherit path." + }, + "INHERITOR_SIG": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "compile_params.INHERITOR_PUB_KEY" + }, + "description": "BIP340 signature from the heir's key." + } + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO to pay the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true + } + ], + "outputs": [ + { + "id": "to_heir", + "description": "The inherited funds, sent to the heir's wallet.", + "destination": "wallet", + "asset": "lbtc", + "amount_sat": "will_in.amount_sat" + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending.manifest.json b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending.manifest.json new file mode 100644 index 0000000..03bb084 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending.manifest.json @@ -0,0 +1,1575 @@ +{ + "manifest_version": "0.1.0", + "protocol": "simplicity-lending", + "description": "P2P collateralised lending protocol on Liquid using SimplicityHL covenants. Borrower locks collateral in a PreLockCovenant and advertises terms via bit-packed Parameter NFTs. A Lender accepts by providing the principal, activating the LendingCovenant. Settlement is either repayment (borrower returns principal+interest, reclaims collateral) or liquidation (lender claims collateral after loan expiry). All covenants are enforced on-chain via Simplicity programs; no trusted backend is required.", + "simplicity_hl_version": "0.6.0", + "utxo_types": { + "p2pk": { + "description": "Simple Schnorr-signature covenant (p2pk.simf) keyed to BORROWER_PUB_KEY. Used as the borrower's principal-payment and NFT-release address.", + "script": { + "type": "simplicity", + "source": "./p2pk.simf", + "compile_params": { + "PUB_KEY": "BORROWER_PUB_KEY" + } + }, + "confidential": false + }, + "pre_lock": { + "description": "Collateral held in the PreLockCovenant while the borrower's offer is open. Two spending paths: PATH::LEFT (create_lending_path) allows a lender to activate the loan; PATH::RIGHT (cancel_pre_lock_path) allows the borrower to cancel with a Schnorr signature, burning all Utility NFTs.", + "script": { + "type": "simplicity", + "source": "./pre_lock.simf" + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "lending_collateral": { + "description": "Collateral locked in the active LendingCovenant. Released via repayment (borrower returns principal+interest, reclaims collateral) or liquidation (lender claims collateral after LOAN_EXPIRATION_TIME).", + "script": { + "type": "simplicity", + "source": "./lending.simf" + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "prelock_script_auth": { + "description": "ScriptAuth covenant wrapping Utility NFTs during the offer phase. Verifies co-spending with the pre_lock UTXO.", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "compile_params": { + "SCRIPT_HASH": "PRE_LOCK_COV_HASH" + } + }, + "confidential": false + }, + "lending_script_auth": { + "description": "ScriptAuth covenant wrapping Parameter NFTs and the Borrower NFT during the active loan phase. Verifies co-spending with the lending_collateral UTXO.", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "compile_params": { + "SCRIPT_HASH": "LENDING_COV_HASH" + } + }, + "confidential": false + }, + "lender_principal_vault": { + "description": "AssetAuth covenant holding the principal+interest payment from the borrower. The lender withdraws by co-spending and burning the Lender NFT.", + "script": { + "type": "simplicity", + "source": "./asset_auth.simf", + "compile_params": { + "ASSET_ID": "LENDER_NFT_ASSET_ID", + "ASSET_AMOUNT": "1", + "WITH_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + } + }, + "actions": { + "Prepare": { + "description": "Utility method to split a utxo into 4 utxos for the IssueUtilityNFTs action", + "inputs": [ + { + "id": "input", + "description": "Wallet UTXO to split into 4 for IssueUtilityNFTs", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "split_utxo1", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo2", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo3", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo4", + "destination": "change", + "asset": "lbtc" + } + ] + }, + "PrepareLender": { + "description": "Utility: ensure the lender wallet has a PRINCIPAL_AMOUNT-sat UTXO of PRINCIPAL_ASSET_ID ready for SetupLending. Accepts any UTXO of that asset and splits off the exact amount needed.", + "inputs": [ + { + "id": "principal_input", + "description": "Any wallet UTXO holding PRINCIPAL_ASSET_ID (at least PRINCIPAL_AMOUNT sat).", + "utxo_source": "wallet", + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "compile_params.PRINCIPAL_AMOUNT" + } + } + ], + "outputs": [ + { + "id": "principal_out", + "description": "Exact PRINCIPAL_AMOUNT sat ready for SetupLending principal_in.", + "destination": "wallet", + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "amount_sat": "compile_params.PRINCIPAL_AMOUNT", + "confidential": false + } + ] + } + }, + "classes": { + "lending_contract": { + "description": "P2P collateralised lending contract. One instance per loan offer. Created by IssueUtilityNFTs; advanced through LockCollateral, SetupLending, RepayLoan / LiquidateAfterExpiry, and ClaimPrincipalWithInterest.", + "fields": { + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "BORROWER_PUB_KEY": { + "type": "pubkey" + }, + "COLLATERAL_AMOUNT": { + "type": "u64" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id" + }, + "COLLATERAL_DECIMALS_MANTISSA": { + "type": "u8" + }, + "FIRST_PARAMETERS_ENCODED": { + "type": "u64" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "LENDER_PRINCIPAL_COV_HASH": { + "type": "bytes32" + }, + "LENDING_COV_HASH": { + "type": "bytes32" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32" + }, + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "PRE_LOCK_COV_HASH": { + "type": "bytes32" + }, + "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": { + "type": "bytes32" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id" + }, + "PRINCIPAL_DECIMALS_MANTISSA": { + "type": "u8" + }, + "PRINCIPAL_INTEREST_AMOUNT": { + "type": "u64" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "SECOND_PARAMETERS_ENCODED": { + "type": "u64" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id" + } + }, + "methods": { + "IssueUtilityNFTs": { + "is_constructor": true, + "description": "Borrower issues four Liquid NFTs (Borrower NFT, Lender NFT, First/Second Parameters NFTs). Computes all covenant hashes and writes the instance file.", + "params": { + "BORROWER_PUB_KEY": { + "type": "pubkey", + "source": { + "type": "wallet_key" + }, + "description": "Borrower's BIP340 Schnorr public key. Used for cancellation authorization and as the destination for the principal output." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Liquid asset ID of the collateral (e.g., L-BTC)." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Exact collateral amount in asset base units." + }, + "COLLATERAL_DECIMALS_MANTISSA": { + "type": "u8", + "default": "8", + "description": "Decimal exponent of the collateral asset (e.g., 8 for L-BTC). Used for parameter NFT encoding." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Liquid asset ID of the loan currency (e.g., L-USDT)." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Exact principal amount requested, in asset base units." + }, + "PRINCIPAL_DECIMALS_MANTISSA": { + "type": "u8", + "description": "Decimal exponent of the principal asset." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "description": "Interest rate in basis points (10,000 = 100%). Max 65,535 basis points." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "Block height at or after which the lender may liquidate (CLTV)." + } + }, + "on_pre_broadcast": { + "set": { + "compile_params.FIRST_PARAMETERS_ENCODED": "params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.COLLATERAL_DECIMALS_MANTISSA * 8796093022208 + params.PRINCIPAL_DECIMALS_MANTISSA * 140737488355328", + "compile_params.SECOND_PARAMETERS_ENCODED": "params.COLLATERAL_AMOUNT / pow(10, COLLATERAL_DECIMALS_MANTISSA) + params.PRINCIPAL_AMOUNT / pow(10, PRINCIPAL_DECIMALS_MANTISSA) * 33554432" + } + }, + "create_instance": { + "class": "lending_contract", + "fields": { + "BORROWER_NFT_ASSET_ID": "$compile_params.BORROWER_NFT_ASSET_ID", + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { + "compute": "tapleaf", + "simf": "./script_auth.simf", + "params": { + "SCRIPT_HASH": { + "type": "bytes32", + "value": "LENDING_COV_HASH" + } + } + }, + "BORROWER_PUB_KEY": "$params.BORROWER_PUB_KEY", + "COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "COLLATERAL_DECIMALS_MANTISSA": "$params.COLLATERAL_DECIMALS_MANTISSA", + "FIRST_PARAMETERS_ENCODED": "$compile_params.FIRST_PARAMETERS_ENCODED", + "FIRST_PARAMETERS_NFT_ASSET_ID": "$compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "$compile_params.LENDER_NFT_ASSET_ID", + "LENDER_PRINCIPAL_COV_HASH": { + "compute": "tapleaf", + "simf": "./asset_auth.simf", + "params": { + "ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "WITH_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "LENDING_COV_HASH": { + "compute": "tapleaf", + "simf": "./lending.simf", + "params": { + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "FIRST_PARAMETERS_NFT_ASSET_ID" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "SECOND_PARAMETERS_NFT_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "LENDER_PRINCIPAL_COV_HASH": { + "type": "bytes32", + "value": "LENDER_PRINCIPAL_COV_HASH" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + } + } + }, + "LOAN_EXPIRATION_TIME": "$params.LOAN_EXPIRATION_TIME", + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "compute": "tapleaf", + "simf": "./script_auth.simf", + "params": { + "SCRIPT_HASH": { + "type": "bytes32", + "value": "LENDING_COV_HASH" + } + } + }, + "PRE_LOCK_COV_HASH": { + "compute": "tapleaf", + "simf": "./pre_lock.simf", + "params": { + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "FIRST_PARAMETERS_NFT_ASSET_ID" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "SECOND_PARAMETERS_NFT_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "LENDING_COV_HASH": { + "type": "bytes32", + "value": "LENDING_COV_HASH" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + }, + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" + }, + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "BORROWER_NFT_OUTPUT_SCRIPT_HASH" + }, + "BORROWER_PUB_KEY": { + "type": "pubkey", + "value": "BORROWER_PUB_KEY" + } + } + }, + "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": { + "compute": "tapleaf", + "simf": "./script_auth.simf", + "params": { + "SCRIPT_HASH": { + "type": "bytes32", + "value": "PRE_LOCK_COV_HASH" + } + } + }, + "PRINCIPAL_AMOUNT": "$params.PRINCIPAL_AMOUNT", + "PRINCIPAL_ASSET_ID": "$params.PRINCIPAL_ASSET_ID", + "PRINCIPAL_DECIMALS_MANTISSA": "$params.PRINCIPAL_DECIMALS_MANTISSA", + "PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000", + "PRINCIPAL_INTEREST_RATE": "$params.PRINCIPAL_INTEREST_RATE", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "compute": "tapleaf", + "simf": "./p2pk.simf", + "params": { + "PUB_KEY": { + "type": "pubkey", + "value": "BORROWER_PUB_KEY" + } + } + }, + "SECOND_PARAMETERS_ENCODED": "$compile_params.SECOND_PARAMETERS_ENCODED", + "SECOND_PARAMETERS_NFT_ASSET_ID": "$compile_params.SECOND_PARAMETERS_NFT_ASSET_ID" + } + }, + "inputs": [ + { + "id": "borrower_nft_issuance_input", + "description": "Wallet UTXO whose outpoint determines BORROWER_NFT_ASSET_ID.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "compile_params.BORROWER_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "lender_nft_issuance_input", + "description": "Wallet UTXO whose outpoint determines LENDER_NFT_ASSET_ID.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "compile_params.LENDER_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "first_params_issuance_input", + "description": "Wallet UTXO whose outpoint determines FIRST_PARAMETERS_NFT_ASSET_ID.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED", + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "second_params_issuance_input", + "description": "Wallet UTXO whose outpoint determines SECOND_PARAMETERS_NFT_ASSET_ID.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED", + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID": "asset" + } + } + } + ], + "outputs": [ + { + "id": "borrower_nft_out", + "description": "Borrower auth NFT. Amount=1, no reissuance.", + "destination": "wallet", + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false + }, + { + "id": "lender_nft_out", + "description": "Lender auth NFT. Amount=1, no reissuance.", + "destination": "wallet", + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false + }, + { + "id": "first_params_nft_out", + "description": "First Parameter NFT carrying bit-packed loan terms in amount field.", + "destination": "wallet", + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED", + "confidential": false + }, + { + "id": "second_params_nft_out", + "description": "Second Parameter NFT carrying bit-packed base amounts in amount field.", + "destination": "wallet", + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED", + "confidential": false + }, + { + "id": "fee_change", + "description": "L-BTC change back to borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "collateral_principal_distinct", + "description": "Collateral and principal must be different assets.", + "rule": { + "type": "arithmetic", + "expr": "params.COLLATERAL_ASSET_ID != params.PRINCIPAL_ASSET_ID" + }, + "error": { + "code": "ASSET_COLLISION", + "message": "COLLATERAL_ASSET_ID and PRINCIPAL_ASSET_ID must be different assets." + } + } + ] + }, + "LockCollateral": { + "description": "Borrower locks collateral and all four Utility NFTs into their covenant addresses, creating the open offer on-chain.", + "inputs": [ + { + "id": "collateral_in", + "description": "Borrower's collateral UTXO.", + "utxo_source": "wallet", + "asset": "compile_params.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "compile_params.COLLATERAL_AMOUNT" + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT from wallet.", + "utxo_source": "wallet", + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "lender_nft_in", + "description": "Lender NFT from wallet.", + "utxo_source": "wallet", + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "first_params_in", + "description": "First Parameter NFT from wallet.", + "utxo_source": "wallet", + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED" + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT from wallet.", + "utxo_source": "wallet", + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED" + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "pre_lock_out", + "description": "Collateral locked in the PreLockCovenant.", + "destination": { + "utxo_type": "pre_lock" + }, + "asset": "compile_params.COLLATERAL_ASSET_ID", + "amount_sat": "compile_params.COLLATERAL_AMOUNT" + }, + { + "id": "borrower_nft_locked", + "description": "Borrower NFT locked in prelock ScriptAuth.", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "lender_nft_locked", + "description": "Lender NFT locked in prelock ScriptAuth.", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "first_params_locked", + "description": "First Parameter NFT locked in prelock ScriptAuth.", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED" + }, + { + "id": "second_params_locked", + "description": "Second Parameter NFT locked in prelock ScriptAuth.", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED" + }, + { + "id": "indexer_op_return", + "description": "OP_RETURN for indexer discovery.", + "destination": { + "type": "op_return" + }, + "data": "concat(compile_params.BORROWER_PUB_KEY, compile_params.PRINCIPAL_ASSET_ID)" + }, + { + "id": "collateral_change", + "description": "Collateral change back to borrower.", + "destination": "change", + "asset": "compile_params.COLLATERAL_ASSET_ID", + "optional": true + }, + { + "id": "fee_change", + "description": "L-BTC change back to borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "collateral_amount_matches", + "description": "Collateral input must exactly match COLLATERAL_AMOUNT.", + "rule": { + "type": "arithmetic", + "expr": "collateral_in.amount_sat == compile_params.COLLATERAL_AMOUNT" + }, + "error": { + "code": "AMOUNT_MISMATCH", + "message": "Collateral input amount does not match COLLATERAL_AMOUNT" + } + } + ] + }, + "CancelOffer": { + "description": "Borrower cancels the open offer. Requires BORROWER_PUB_KEY signature. Collateral returned; all Utility NFTs burned. Spends pre_lock via PATH::RIGHT.", + "inputs": [ + { + "id": "pre_lock_in", + "description": "Pre-lock collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "pre_lock" + }, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Right(())", + "description": "Selects cancel_pre_lock_path (PATH::RIGHT)." + }, + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "compile_params.BORROWER_PUB_KEY" + }, + "description": "BIP340 Schnorr signature from BORROWER_PUB_KEY." + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "pre_lock_leaf" + } + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in prelock ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "prelock_script_auth_leaf" + } + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in prelock ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "prelock_script_auth_leaf" + } + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT in prelock ScriptAuth (index 3).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "prelock_script_auth_leaf" + } + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT in prelock ScriptAuth (index 4).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "prelock_script_auth_leaf" + } + } + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "collateral_returned", + "destination": "wallet", + "asset": "compile_params.COLLATERAL_ASSET_ID", + "amount_sat": "pre_lock_in.amount_sat" + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat" + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat" + }, + { + "id": "borrower_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "lender_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "SetupLending": { + "description": "Lender activates the loan by spending the pre_lock covenant (PATH::LEFT). Collateral moves to LendingCovenant; principal delivered to borrower.", + "inputs": [ + { + "id": "collateral_in", + "description": "Pre-lock collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "pre_lock" + }, + "required_index": 0, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Left(())", + "description": "Selects create_lending_path (PATH::LEFT)." + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "pre_lock_leaf" + } + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in prelock ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 1, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "prelock_script_auth_leaf" + } + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in prelock ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 2, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "prelock_script_auth_leaf" + } + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT in prelock ScriptAuth (index 3).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 3, + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "prelock_script_auth_leaf" + } + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT in prelock ScriptAuth (index 4).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 4, + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "prelock_script_auth_leaf" + } + } + } + }, + { + "id": "principal_in", + "description": "Lender's wallet UTXO providing the principal.", + "utxo_source": "wallet", + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "compile_params.PRINCIPAL_AMOUNT" + }, + "required_index": 5 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 6 + } + ], + "outputs": [ + { + "id": "lending_collateral_out", + "description": "Collateral at LendingCovenant (output 0).", + "destination": { + "utxo_type": "lending_collateral" + }, + "asset": "compile_params.COLLATERAL_ASSET_ID", + "amount_sat": "compile_params.COLLATERAL_AMOUNT", + "required_index": 0, + "confidential": false + }, + { + "id": "principal_to_borrower", + "description": "Principal to borrower P2PK (output 1).", + "destination": { + "utxo_type": "p2pk" + }, + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "amount_sat": "compile_params.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 1 + }, + { + "id": "first_params_relocked", + "description": "First Parameter NFT under lending ScriptAuth (output 2).", + "destination": { + "utxo_type": "lending_script_auth" + }, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 2, + "confidential": false + }, + { + "id": "second_params_relocked", + "description": "Second Parameter NFT under lending ScriptAuth (output 3).", + "destination": { + "utxo_type": "lending_script_auth" + }, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 3, + "confidential": false + }, + { + "id": "borrower_nft_released", + "description": "Borrower NFT under lending ScriptAuth (output 4).", + "destination": { + "utxo_type": "lending_script_auth" + }, + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 4, + "confidential": false + }, + { + "id": "lender_nft_released", + "description": "Lender NFT to lender's wallet (output 5).", + "destination": "wallet", + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 5, + "confidential": false + }, + { + "id": "principal_change", + "destination": "change", + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "optional": true, + "required_index": -2 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true, + "required_index": -1 + } + ] + }, + "ClaimLoanFunds": { + "description": "As a borrower, claim the funds from a p2pk.simf into a normal wallet output.", + "inputs": [ + { + "id": "principal_in", + "description": "Principal UTXO at borrower P2PK (index 0).", + "utxo_source": { + "utxo_type": "p2pk" + }, + "witnesses": { + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "p2pk_leaf" + } + }, + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "$params.BORROWER_PUB_KEY" + }, + "description": "BIP340 Schnorr signature from BORROWER_PUB_KEY." + } + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees (optional if principal is L-BTC).", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true + } + ], + "outputs": [ + { + "id": "principal_to_borrower", + "description": "Principal to borrower wallet (output 0).", + "destination": "wallet", + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "amount_sat": "principal_in.amount_sat" + }, + { + "id": "fee_change", + "description": "L-BTC change back to borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "RepayLoan": { + "description": "Borrower repays principal+interest and reclaims collateral. Spends lending_collateral via PATH::LEFT.", + "inputs": [ + { + "id": "lending_in", + "description": "Active lending collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Left(())", + "description": "Selects loan_repayment_path (PATH::LEFT)." + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "lending_leaf" + } + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in lending ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "lending_script_auth_leaf" + } + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in lending ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "lending_script_auth_leaf" + } + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT in lending ScriptAuth (index 3).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "lending_script_auth_leaf" + } + } + } + }, + { + "id": "repayment_in", + "description": "Borrower's wallet UTXO providing principal+interest.", + "utxo_source": "wallet", + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees (optional if principal is L-BTC).", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true + } + ], + "outputs": [ + { + "id": "collateral_returned", + "description": "Collateral returned to borrower (output 0).", + "destination": "wallet", + "asset": "compile_params.COLLATERAL_ASSET_ID", + "amount_sat": "compile_params.COLLATERAL_AMOUNT", + "confidential": false + }, + { + "id": "principal_interest_to_vault", + "description": "Principal+interest to lender vault at LENDER_PRINCIPAL_COV_HASH (output 1).", + "destination": { + "utxo_type": "lender_principal_vault" + }, + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "amount_sat": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT", + "confidential": false + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat" + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat" + }, + { + "id": "borrower_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.BORROWER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "repayment_change", + "destination": "change", + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "optional": true + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "LiquidateAfterExpiry": { + "description": "Lender claims collateral after loan expiry. Spends lending_collateral via PATH::RIGHT.", + "inputs": [ + { + "id": "lending_in", + "description": "Active lending collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Right(())", + "description": "Selects loan_liquidation_path (PATH::RIGHT)." + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "lending_leaf" + } + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in lending ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "lending_script_auth_leaf" + } + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in lending ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "lending_script_auth_leaf" + } + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT from wallet (index 3).", + "utxo_source": "wallet", + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "collateral_to_lender", + "description": "Collateral sent to lender (output 0).", + "destination": "wallet", + "asset": "compile_params.COLLATERAL_ASSET_ID", + "amount_sat": "compile_params.COLLATERAL_AMOUNT" + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat" + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat" + }, + { + "id": "lender_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "expiry_reached", + "description": "Current block height must be >= LOAN_EXPIRATION_TIME.", + "rule": { + "type": "arithmetic", + "expr": "current_block_height >= compile_params.LOAN_EXPIRATION_TIME" + }, + "error": { + "code": "TIMELOCK_NOT_ELAPSED", + "message": "Loan has not yet expired. Cannot liquidate before LOAN_EXPIRATION_TIME." + } + } + ] + }, + "ClaimPrincipalWithInterest": { + "description": "Lender withdraws principal+interest from the lender_principal_vault by co-spending and burning the Lender NFT.", + "inputs": [ + { + "id": "vault_in", + "description": "The lender_principal_vault UTXO.", + "utxo_source": { + "utxo_type": "lender_principal_vault" + }, + "witnesses": { + "INPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1" + }, + "OUTPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1" + }, + "SPEND_PATH": { + "type": "taproot_leaf", + "source": { + "type": "formula", + "expr": "lender_principal_vault_leaf" + } + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT (amount=1).", + "utxo_source": "wallet", + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "principal_interest_out", + "description": "Principal+interest delivered to lender.", + "destination": "wallet", + "asset": "compile_params.PRINCIPAL_ASSET_ID", + "amount_sat": "vault_in.amount_sat" + }, + { + "id": "lender_nft_burned", + "description": "Lender NFT burned (required by WITH_ASSET_BURN=true).", + "destination": { + "type": "op_return" + }, + "asset": "compile_params.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + } + } + } + }, + "lifecycle": { + "states": [ + "nfts_issued", + "offer_open", + "loan_active", + "repaid", + "liquidated", + "cancelled" + ], + "entry_actions": [ + "IssueUtilityNFTs" + ], + "transitions": { + "IssueUtilityNFTs": { + "to": "nfts_issued" + }, + "LockCollateral": { + "from": "nfts_issued", + "to": "offer_open" + }, + "CancelOffer": { + "from": "offer_open", + "to": "cancelled", + "unilateral": true + }, + "SetupLending": { + "from": "offer_open", + "to": "loan_active" + }, + "RepayLoan": { + "from": "loan_active", + "to": "repaid", + "cooperative": true + }, + "LiquidateAfterExpiry": { + "from": "loan_active", + "to": "liquidated", + "unilateral": true + }, + "ClaimPrincipalWithInterest": { + "from": "repaid", + "to": "settled" + } + } + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v2.manifest.json b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v2.manifest.json new file mode 100644 index 0000000..b8c5ee9 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v2.manifest.json @@ -0,0 +1,1439 @@ +{ + "manifest_version": "0.1.0", + "protocol": "simplicity-lending", + "description": "P2P collateralised lending on Liquid, wire-compatible with the simplicity-lending reference implementation (github BlockstreamResearch/simplicity-lending, `smplx-sdk` covenants). This 'v2' manifest reproduces that protocol's exact on-chain transaction layout so offers created with tx-manifest-wallet are discoverable and settleable by simplicity-lending's own CLI / indexer / web app, and vice-versa. Key differences from the standalone 'lending' example: (1) the principal payout AND the borrower NFT both go to the borrower's plain wallet address (an explicit v0 P2WPKH), committed into the covenant as sha256(scriptPubKey) — there is no p2pk covenant and no separate claim step; (2) the borrower NFT is held in the borrower's wallet during the active loan and spent with an ordinary signature on repayment; (3) NFT ordering is first-params, second-params, borrower, lender; (4) the creation OP_RETURN carries borrower_pubkey || principal_asset_id (64 bytes, internal asset order) for indexer discovery; (5) a single AMOUNTS_DECIMALS drives the bit-packed parameter NFTs, matching the reference wallet.", + "simplicity_hl_version": "0.6.0", + "compile_debug_symbols": true, + "utxo_types": { + "pre_lock": { + "description": "Collateral held in the PreLockCovenant while the borrower's offer is open. PATH::LEFT (create_lending_path) lets a lender activate the loan; PATH::RIGHT (cancel_pre_lock_path) lets the borrower cancel with a Schnorr signature, burning all Utility NFTs. Address is byte-identical to simplicity-lending's pre_lock covenant for the same parameters.", + "script": { + "type": "simplicity", + "source": "./pre_lock.simf" + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "lending_collateral": { + "description": "Collateral locked in the active LendingCovenant. Released via repayment (borrower returns principal+interest, reclaims collateral) or liquidation (lender claims collateral after LOAN_EXPIRATION_TIME).", + "script": { + "type": "simplicity", + "source": "./lending.simf" + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "prelock_script_auth": { + "description": "ScriptAuth covenant wrapping the Utility NFTs during the offer phase. Verifies co-spending with the pre_lock UTXO (input 0).", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "compile_params": { + "SCRIPT_HASH": "PRE_LOCK_COV_HASH" + } + }, + "confidential": false + }, + "lending_script_auth": { + "description": "ScriptAuth covenant wrapping the two Parameter NFTs during the active loan phase. Verifies co-spending with the lending_collateral UTXO (input 0). Unlike the standalone example, the Borrower NFT is NOT wrapped here — it lives in the borrower's wallet.", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "compile_params": { + "SCRIPT_HASH": "LENDING_COV_HASH" + } + }, + "confidential": false + }, + "lender_principal_vault": { + "description": "AssetAuth covenant holding the principal+interest payment from the borrower. The lender withdraws by co-spending and burning the Lender NFT.", + "script": { + "type": "simplicity", + "source": "./asset_auth.simf", + "compile_params": { + "ASSET_ID": "LENDER_NFT_ASSET_ID", + "ASSET_AMOUNT": "1", + "WITH_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + } + }, + "actions": { + "Prepare": { + "description": "Utility method to split a wallet UTXO into 4 L-BTC UTXOs for the four issuances in IssueUtilityNFTs.", + "inputs": [ + { + "id": "input", + "description": "Wallet UTXO to split into 4 for IssueUtilityNFTs", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "split_utxo1", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo2", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo3", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo4", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo5", + "destination": "change", + "asset": "lbtc" + } + ] + }, + "PrepareLender": { + "description": "Utility: ensure the lender wallet has a PRINCIPAL_AMOUNT-sat UTXO of PRINCIPAL_ASSET_ID ready for SetupLending. Accepts any UTXO of that asset and splits off the exact amount needed.", + "inputs": [ + { + "id": "principal_input", + "description": "Any wallet UTXO holding PRINCIPAL_ASSET_ID (at least PRINCIPAL_AMOUNT sat).", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT" + } + } + ], + "outputs": [ + { + "id": "principal_out", + "description": "Exact PRINCIPAL_AMOUNT sat ready for SetupLending principal_in.", + "destination": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false + } + ] + } + }, + "classes": { + "lending_contract": { + "description": "P2P collateralised lending contract, wire-compatible with simplicity-lending. One instance per loan offer. Created by IssueUtilityNFTs; advanced through LockCollateral, SetupLending, RepayLoan / LiquidateAfterExpiry, and ClaimPrincipalWithInterest.", + "fields": { + "AMOUNTS_DECIMALS": { + "type": "u8" + }, + "BORROWER_ADDRESS": { + "type": "string" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "BORROWER_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "BORROWER_PUB_KEY": { + "type": "pubkey" + }, + "COLLATERAL_AMOUNT": { + "type": "u64" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id" + }, + "FIRST_PARAMETERS_ENCODED": { + "type": "u64" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "LENDER_PRINCIPAL_COV_HASH": { + "type": "bytes32" + }, + "LENDING_COV_HASH": { + "type": "bytes32" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32" + }, + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "PRE_LOCK_COV_HASH": { + "type": "bytes32" + }, + "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": { + "type": "bytes32" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id" + }, + "PRINCIPAL_INTEREST_AMOUNT": { + "type": "u64" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "SECOND_PARAMETERS_ENCODED": { + "type": "u64" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id" + } + }, + "methods": { + "IssueUtilityNFTs": { + "is_constructor": true, + "description": "Borrower issues four Liquid NFTs (First/Second Parameters NFTs, Borrower NFT, Lender NFT — in that output order). Computes all covenant hashes and the borrower payout commitment, then writes the instance file.", + "params": { + "BORROWER_PUB_KEY": { + "type": "pubkey", + "source": { + "type": "wallet_key" + }, + "description": "Borrower's BIP340 Schnorr public key. Used for offer cancellation authorization and stored in the discovery OP_RETURN." + }, + "BORROWER_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "source": { + "type": "wallet_script_hash" + }, + "description": "SHA-256 of the borrower's explicit (unblinded) index-0 P2WPKH scriptPubKey. Committed into the covenant as both PRINCIPAL_OUTPUT_SCRIPT_HASH and BORROWER_NFT_OUTPUT_SCRIPT_HASH — this is exactly what simplicity-lending bakes via hash_script(signer.get_address().script_pubkey())." + }, + "BORROWER_ADDRESS": { + "type": "string", + "source": { + "type": "wallet_address" + }, + "description": "The borrower's explicit (unblinded) index-0 address string. The principal and Borrower NFT are paid here on activation; its scriptPubKey hashes to BORROWER_OUTPUT_SCRIPT_HASH. Fund collateral from this same address so a third-party lender can reconstruct the offer." + }, + "AMOUNTS_DECIMALS": { + "type": "u8", + "default": "0", + "description": "Single decimal mantissa applied to BOTH collateral and principal when bit-packing the Parameter NFTs. The bit-packed amount is `amount / 10^AMOUNTS_DECIMALS` and must round-trip exactly, so COLLATERAL_AMOUNT and PRINCIPAL_AMOUNT must each be exact multiples of 10^AMOUNTS_DECIMALS, and each `amount / 10^AMOUNTS_DECIMALS` must fit in 25 bits (< 33,554,432). Default 0 is lossless for any amount below 33,554,432 (use this unless your amounts are larger). Raise it (e.g. to 1, matching simplicity-lending's demo) only with amounts that are exact multiples of 10^AMOUNTS_DECIMALS — a non-divisible amount is silently truncated in the NFT, which makes the offer both unindexable and unspendable." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Liquid asset ID of the collateral (e.g., L-BTC)." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Exact collateral amount in asset base units (multiple of 10^AMOUNTS_DECIMALS)." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Liquid asset ID of the loan currency (e.g., a test USDT)." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Exact principal amount requested, in asset base units (multiple of 10^AMOUNTS_DECIMALS)." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "description": "Interest rate in basis points (10,000 = 100%). Max 65,535 basis points." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "Block height at or after which the lender may liquidate (CLTV). Max 2^27-1." + } + }, + "on_pre_broadcast": { + "set": { + "instance.FIRST_PARAMETERS_ENCODED": "params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.AMOUNTS_DECIMALS * 8796093022208 + params.AMOUNTS_DECIMALS * 140737488355328", + "instance.SECOND_PARAMETERS_ENCODED": "params.COLLATERAL_AMOUNT / pow(10, AMOUNTS_DECIMALS) + params.PRINCIPAL_AMOUNT / pow(10, AMOUNTS_DECIMALS) * 33554432" + } + }, + "create_instance": { + "class": "lending_contract", + "fields": { + "AMOUNTS_DECIMALS": "$params.AMOUNTS_DECIMALS", + "BORROWER_ADDRESS": "$params.BORROWER_ADDRESS", + "BORROWER_NFT_ASSET_ID": "$instance.BORROWER_NFT_ASSET_ID", + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": "$params.BORROWER_OUTPUT_SCRIPT_HASH", + "BORROWER_OUTPUT_SCRIPT_HASH": "$params.BORROWER_OUTPUT_SCRIPT_HASH", + "BORROWER_PUB_KEY": "$params.BORROWER_PUB_KEY", + "COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "FIRST_PARAMETERS_ENCODED": "$instance.FIRST_PARAMETERS_ENCODED", + "FIRST_PARAMETERS_NFT_ASSET_ID": "$instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "$instance.LENDER_NFT_ASSET_ID", + "LENDER_PRINCIPAL_COV_HASH": { + "compute": "tapleaf", + "simf": "./asset_auth.simf", + "params": { + "ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "WITH_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "LENDING_COV_HASH": { + "compute": "tapleaf", + "simf": "./lending.simf", + "params": { + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "FIRST_PARAMETERS_NFT_ASSET_ID" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "SECOND_PARAMETERS_NFT_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "LENDER_PRINCIPAL_COV_HASH": { + "type": "bytes32", + "value": "LENDER_PRINCIPAL_COV_HASH" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + } + } + }, + "LOAN_EXPIRATION_TIME": "$params.LOAN_EXPIRATION_TIME", + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "compute": "tapleaf", + "simf": "./script_auth.simf", + "params": { + "SCRIPT_HASH": { + "type": "bytes32", + "value": "LENDING_COV_HASH" + } + } + }, + "PRE_LOCK_COV_HASH": { + "compute": "tapleaf", + "simf": "./pre_lock.simf", + "params": { + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "FIRST_PARAMETERS_NFT_ASSET_ID" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "SECOND_PARAMETERS_NFT_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "LENDING_COV_HASH": { + "type": "bytes32", + "value": "LENDING_COV_HASH" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + }, + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" + }, + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "BORROWER_NFT_OUTPUT_SCRIPT_HASH" + }, + "BORROWER_PUB_KEY": { + "type": "pubkey", + "value": "BORROWER_PUB_KEY" + } + } + }, + "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": { + "compute": "tapleaf", + "simf": "./script_auth.simf", + "params": { + "SCRIPT_HASH": { + "type": "bytes32", + "value": "PRE_LOCK_COV_HASH" + } + } + }, + "PRINCIPAL_AMOUNT": "$params.PRINCIPAL_AMOUNT", + "PRINCIPAL_ASSET_ID": "$params.PRINCIPAL_ASSET_ID", + "PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000", + "PRINCIPAL_INTEREST_RATE": "$params.PRINCIPAL_INTEREST_RATE", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "$params.BORROWER_OUTPUT_SCRIPT_HASH", + "SECOND_PARAMETERS_ENCODED": "$instance.SECOND_PARAMETERS_ENCODED", + "SECOND_PARAMETERS_NFT_ASSET_ID": "$instance.SECOND_PARAMETERS_NFT_ASSET_ID" + } + }, + "inputs": [ + { + "id": "collateral_prefund_in", + "description": "Wallet UTXO holding at least COLLATERAL_AMOUNT of the collateral asset. Consumed to mint a dedicated, exact-amount collateral UTXO at BORROWER_ADDRESS (see collateral_at_borrower output) so LockCollateral can spend it from the committed address. Declared first so it claims a large-enough UTXO before the 1-sat issuance inputs.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.COLLATERAL_AMOUNT" + } + }, + { + "id": "first_params_issuance_input", + "description": "Wallet UTXO whose outpoint determines FIRST_PARAMETERS_NFT_ASSET_ID (issued at output 0).", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "instance.FIRST_PARAMETERS_ENCODED", + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "second_params_issuance_input", + "description": "Wallet UTXO whose outpoint determines SECOND_PARAMETERS_NFT_ASSET_ID (issued at output 1).", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "instance.SECOND_PARAMETERS_ENCODED", + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.SECOND_PARAMETERS_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "borrower_nft_issuance_input", + "description": "Wallet UTXO whose outpoint determines BORROWER_NFT_ASSET_ID (issued at output 2).", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.BORROWER_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "lender_nft_issuance_input", + "description": "Wallet UTXO whose outpoint determines LENDER_NFT_ASSET_ID (issued at output 3).", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.LENDER_NFT_ASSET_ID": "asset" + } + } + } + ], + "outputs": [ + { + "id": "first_params_nft_out", + "description": "First Parameter NFT carrying bit-packed loan terms in the amount field (output 0).", + "destination": "wallet", + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.FIRST_PARAMETERS_ENCODED", + "confidential": false + }, + { + "id": "second_params_nft_out", + "description": "Second Parameter NFT carrying bit-packed base amounts in the amount field (output 1).", + "destination": "wallet", + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.SECOND_PARAMETERS_ENCODED", + "confidential": false + }, + { + "id": "borrower_nft_out", + "description": "Borrower auth NFT (output 2). Amount=1.", + "destination": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false + }, + { + "id": "lender_nft_out", + "description": "Lender auth NFT (output 3). Amount=1.", + "destination": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false + }, + { + "id": "collateral_at_borrower", + "description": "Dedicated collateral UTXO of exactly COLLATERAL_AMOUNT at the borrower's committed address (explicit). LockCollateral spends this (pinned via from_address), guaranteeing its input 0 is funded from the address whose hash the covenant commits to — which is what makes the offer indexable.", + "destination": "instance.BORROWER_ADDRESS", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false + }, + { + "id": "collateral_change", + "description": "Collateral-asset change back to borrower (surplus over COLLATERAL_AMOUNT).", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true + }, + { + "id": "fee_change", + "description": "L-BTC change back to borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "collateral_principal_distinct", + "description": "Collateral and principal must be different assets.", + "rule": { + "type": "arithmetic", + "expr": "params.COLLATERAL_ASSET_ID != params.PRINCIPAL_ASSET_ID" + }, + "error": { + "code": "ASSET_COLLISION", + "message": "COLLATERAL_ASSET_ID and PRINCIPAL_ASSET_ID must be different assets." + } + } + ] + }, + "LockCollateral": { + "description": "Borrower locks collateral and the four Utility NFTs into their covenant addresses, creating the open offer on-chain. Output order — pre_lock collateral (0), first-params (1), second-params (2), borrower (3), lender (4), discovery OP_RETURN (5) — matches simplicity-lending's create_pre_lock so a third-party lender can activate it. Fund collateral from BORROWER_ADDRESS.", + "inputs": [ + { + "id": "collateral_in", + "description": "Borrower's collateral UTXO — pinned to BORROWER_ADDRESS so it is spent from the exact address whose hash the covenant commits to. This is the UTXO staged by IssueUtilityNFTs, and it is what the indexer reconstructs borrower_output_script_hash from (input 0's prevout).", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "from_address": "instance.BORROWER_ADDRESS", + "amount_sat": "instance.COLLATERAL_AMOUNT" + }, + { + "id": "first_params_in", + "description": "First Parameter NFT from wallet.", + "utxo_source": "wallet", + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT from wallet.", + "utxo_source": "wallet", + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT from wallet.", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "lender_nft_in", + "description": "Lender NFT from wallet.", + "utxo_source": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "pre_lock_out", + "description": "Collateral locked in the PreLockCovenant (output 0).", + "destination": { + "utxo_type": "pre_lock" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "required_index": 0 + }, + { + "id": "first_params_locked", + "description": "First Parameter NFT locked in prelock ScriptAuth (output 1).", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.FIRST_PARAMETERS_ENCODED", + "required_index": 1 + }, + { + "id": "second_params_locked", + "description": "Second Parameter NFT locked in prelock ScriptAuth (output 2).", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.SECOND_PARAMETERS_ENCODED", + "required_index": 2 + }, + { + "id": "borrower_nft_locked", + "description": "Borrower NFT locked in prelock ScriptAuth (output 3).", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "lender_nft_locked", + "description": "Lender NFT locked in prelock ScriptAuth (output 4).", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 4 + }, + { + "id": "indexer_op_return", + "description": "Discovery OP_RETURN (output 5): borrower_pubkey (32) || principal_asset_id (32, internal order) = 64 bytes, exactly simplicity-lending's encode_creation_op_return_data.", + "destination": { + "type": "op_return" + }, + "data": "concat(instance.BORROWER_PUB_KEY, instance.PRINCIPAL_ASSET_ID)", + "required_index": 5 + }, + { + "id": "collateral_change", + "description": "Collateral change back to borrower.", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true + }, + { + "id": "fee_change", + "description": "L-BTC change back to borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "collateral_amount_matches", + "description": "Collateral input must exactly match COLLATERAL_AMOUNT.", + "rule": { + "type": "arithmetic", + "expr": "collateral_in.amount_sat == instance.COLLATERAL_AMOUNT" + }, + "error": { + "code": "AMOUNT_MISMATCH", + "message": "Collateral input amount does not match COLLATERAL_AMOUNT" + } + } + ] + }, + "CancelOffer": { + "description": "Borrower cancels the open offer. Requires a BORROWER_PUB_KEY signature. Collateral returned; all Utility NFTs burned. Spends pre_lock via PATH::RIGHT.", + "inputs": [ + { + "id": "pre_lock_in", + "description": "Pre-lock collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "pre_lock" + }, + "required_index": 0, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Right(())", + "description": "Selects cancel_pre_lock_path (PATH::RIGHT)." + }, + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "instance.BORROWER_PUB_KEY" + }, + "description": "BIP340 Schnorr signature from BORROWER_PUB_KEY." + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in prelock ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 1, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in prelock ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 2, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT in prelock ScriptAuth (index 3).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 3, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT in prelock ScriptAuth (index 4).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 4, + "asset": "instance.LENDER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "collateral_returned", + "description": "Collateral returned to borrower (output 0).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "pre_lock_in.amount_sat", + "confidential": false, + "required_index": 0 + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 1 + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 2 + }, + { + "id": "borrower_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "lender_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 4 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "SetupLending": { + "description": "Lender activates the loan by spending the pre_lock covenant (PATH::LEFT). Collateral moves to LendingCovenant; principal is delivered to the borrower's wallet; the Borrower NFT is returned to the borrower's wallet; the Lender NFT goes to the lender. Layout matches simplicity-lending's create_lending_from_pre_lock.", + "inputs": [ + { + "id": "collateral_in", + "description": "Pre-lock collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "pre_lock" + }, + "required_index": 0, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Left(())", + "description": "Selects create_lending_path (PATH::LEFT)." + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in prelock ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 1, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in prelock ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 2, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT in prelock ScriptAuth (index 3).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 3, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT in prelock ScriptAuth (index 4).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 4, + "asset": "instance.LENDER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "principal_in", + "description": "Lender's wallet UTXO providing the principal (index 5).", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT" + }, + "required_index": 5 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees (index 6).", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 6 + } + ], + "outputs": [ + { + "id": "lending_collateral_out", + "description": "Collateral at LendingCovenant (output 0).", + "destination": { + "utxo_type": "lending_collateral" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "required_index": 0, + "confidential": false + }, + { + "id": "principal_to_borrower", + "description": "Principal delivered to the borrower's explicit wallet address (output 1). scriptPubKey hashes to PRINCIPAL_OUTPUT_SCRIPT_HASH.", + "destination": "instance.BORROWER_ADDRESS", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 1 + }, + { + "id": "first_params_relocked", + "description": "First Parameter NFT under lending ScriptAuth (output 2).", + "destination": { + "utxo_type": "lending_script_auth" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 2, + "confidential": false + }, + { + "id": "second_params_relocked", + "description": "Second Parameter NFT under lending ScriptAuth (output 3).", + "destination": { + "utxo_type": "lending_script_auth" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 3, + "confidential": false + }, + { + "id": "borrower_nft_to_borrower", + "description": "Borrower NFT returned to the borrower's explicit wallet address (output 4). scriptPubKey hashes to BORROWER_NFT_OUTPUT_SCRIPT_HASH.", + "destination": "instance.BORROWER_ADDRESS", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 4 + }, + { + "id": "lender_nft_to_lender", + "description": "Lender NFT to the lender's wallet (output 5).", + "destination": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 5 + }, + { + "id": "principal_change", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "required_index": -2 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true, + "required_index": -1 + } + ] + }, + "RepayLoan": { + "description": "Borrower repays principal+interest and reclaims collateral. Spends lending_collateral via PATH::LEFT. The Borrower NFT is spent from the borrower's wallet with an ordinary signature (index 3). Layout matches simplicity-lending's repay_loan.", + "inputs": [ + { + "id": "lending_in", + "description": "Active lending collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Left(())", + "description": "Selects loan_repayment_path (PATH::LEFT)." + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in lending ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "required_index": 1, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in lending ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "required_index": 2, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT held in the borrower's wallet (index 3). Spent with an ordinary wallet signature.", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "repayment_in", + "description": "Borrower's wallet UTXO providing principal+interest (index 4).", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT" + }, + "required_index": 4 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees (optional if principal is L-BTC).", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true + } + ], + "outputs": [ + { + "id": "collateral_returned", + "description": "Collateral returned to borrower (output 0).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 0 + }, + { + "id": "principal_interest_to_vault", + "description": "Principal+interest to the lender's AssetAuth vault (output 1). scriptPubKey hashes to LENDER_PRINCIPAL_COV_HASH.", + "destination": { + "utxo_type": "lender_principal_vault" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT", + "confidential": false, + "required_index": 1 + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 2 + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 3 + }, + { + "id": "borrower_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 4 + }, + { + "id": "repayment_change", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "LiquidateAfterExpiry": { + "description": "Lender claims collateral after loan expiry. Spends lending_collateral via PATH::RIGHT; the covenant enforces nLockTime >= LOAN_EXPIRATION_TIME (check_lock_height). Layout matches simplicity-lending's liquidate_loan.", + "inputs": [ + { + "id": "lending_in", + "description": "Active lending collateral UTXO (index 0). nSequence enables absolute locktime.", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "sequence": 4294967294, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Right(())", + "description": "Selects loan_liquidation_path (PATH::RIGHT)." + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in lending ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "required_index": 1, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in lending ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "required_index": 2, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT from wallet (index 3).", + "utxo_source": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "collateral_to_lender", + "description": "Collateral sent to lender (output 0).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 0 + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 1 + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 2 + }, + { + "id": "lender_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "validations": [ + { + "id": "expiry_reached", + "description": "Current block height must be >= LOAN_EXPIRATION_TIME.", + "rule": { + "type": "arithmetic", + "expr": "current_block_height >= instance.LOAN_EXPIRATION_TIME" + }, + "error": { + "code": "TIMELOCK_NOT_ELAPSED", + "message": "Loan has not yet expired. Cannot liquidate before LOAN_EXPIRATION_TIME." + } + } + ] + }, + "ClaimPrincipalWithInterest": { + "description": "Lender withdraws principal+interest from the lender_principal_vault by co-spending and burning the Lender NFT.", + "inputs": [ + { + "id": "vault_in", + "description": "The lender_principal_vault UTXO (index 0).", + "utxo_source": { + "utxo_type": "lender_principal_vault" + }, + "required_index": 0, + "witnesses": { + "INPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1" + }, + "OUTPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1" + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT (amount=1), index 1.", + "utxo_source": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "principal_interest_out", + "description": "Principal+interest delivered to lender (output 0).", + "destination": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "vault_in.amount_sat" + }, + { + "id": "lender_nft_burned", + "description": "Lender NFT burned (required by WITH_ASSET_BURN=true), output 1.", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + } + } + } + }, + "lifecycle": { + "states": [ + "nfts_issued", + "offer_open", + "loan_active", + "repaid", + "liquidated", + "cancelled", + "settled" + ], + "entry_actions": [ + "IssueUtilityNFTs" + ], + "transitions": { + "IssueUtilityNFTs": { + "to": "nfts_issued" + }, + "LockCollateral": { + "from": "nfts_issued", + "to": "offer_open" + }, + "CancelOffer": { + "from": "offer_open", + "to": "cancelled", + "unilateral": true + }, + "SetupLending": { + "from": "offer_open", + "to": "loan_active" + }, + "RepayLoan": { + "from": "loan_active", + "to": "repaid", + "cooperative": true + }, + "LiquidateAfterExpiry": { + "from": "loan_active", + "to": "liquidated", + "unilateral": true + }, + "ClaimPrincipalWithInterest": { + "from": "repaid", + "to": "settled" + } + } + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v3.manifest.json b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v3.manifest.json new file mode 100644 index 0000000..43818d1 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v3.manifest.json @@ -0,0 +1,1518 @@ +{ + "$comment": "lending_v3 targets the DEPLOYED simplicity-lending indexer (odev). Interop constants are fixed: factory params (2,0), NUMS key, protocol-fee keeper 38fca2d9…, and the program-id tags below. Change a covenant .simf and you must recompute the matching *_PROGRAM_ID + covenant address.", + "manifest_version": "0.1.0", + "protocol": "simplicity-lending", + "description": "lending_v3 — the redesigned 'issuance factory' lending protocol, wire-compatible with the deployed simplicity-lending indexer/site (odev branch). Phase 3a models factory creation: a persistent issuance_factory covenant plus the wallet-held auth NFT, from which many lending offers are later minted.", + "simplicity_hl_version": "0.6.0", + "compile_debug_symbols": true, + "utxo_types": { + "issuance_factory": { + "description": "The persistent issuance-factory covenant. Holds 1 unit of the factory asset and, when spent (IssueAssets path), mints borrower/lender NFTs for a new offer while recreating itself. Its address depends ONLY on (ISSUING_UTXOS_COUNT, REISSUANCE_FLAGS) — NOT on the factory asset id — so for the deployed (2, 0) it is the fixed spk 5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b (verified reproduced by examples/factory_recon.rs). Compiled with debug symbols to match the deployed CMR.", + "script": { + "type": "simplicity", + "source": "./issuance_factory.simf", + "compile_params": { + "ISSUING_UTXOS_COUNT": "ISSUING_UTXOS_COUNT", + "REISSUANCE_FLAGS": "REISSUANCE_FLAGS" + } + }, + "asset": "FACTORY_ASSET_ID", + "confidential": false + }, + "lending_collateral": { + "description": "The lending (collateral) covenant — out[5] of an offer-creation tx. Holds COLLATERAL_AMOUNT of the collateral asset with 2 taproot storage slots (slot0 = is_active, slot1 = current_debt). Its address is the CMR of lending.simf compiled with the full offer params + the 5 nested AssetAuth/AssetAuthVault cov-hashes (task 07), folded with the 2 storage leaves (task 01/02). Reproduced byte-exactly for live offer 43ab4efe (examples/lending_recon.rs). NOTE: slot1 (current_debt) is dynamic; expressing it as a computed 32-byte storage leaf needs the engine extension tracked in upnext/10.", + "script": { + "type": "simplicity", + "source": "./lending.simf", + "compile_params": { + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", + "BORROWER_NFT_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "LOAN_EXPIRATION_TIME", + "LENDER_VAULT_COV_HASH": "LENDER_VAULT_COV_HASH", + "FINALIZED_LENDER_VAULT_COV_HASH": "FINALIZED_LENDER_VAULT_COV_HASH", + "PROTOCOL_FEE_VAULT_COV_HASH": "PROTOCOL_FEE_VAULT_COV_HASH", + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "instance.CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "lending_collateral_active": { + "description": "The lending (collateral) covenant in the ACTIVE state (after AcceptOffer). Identical to lending_collateral except storage slot0 = is_active = 1 (value 1, byte[31]=0x01), which changes the covenant address. slot1 = CURRENT_DEBT (unchanged on a fresh accept). De-risked by examples/lending_active_recon.rs (address flips vs pending).", + "script": { + "type": "simplicity", + "source": "./lending.simf", + "compile_params": { + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", + "BORROWER_NFT_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "LOAN_EXPIRATION_TIME", + "LENDER_VAULT_COV_HASH": "LENDER_VAULT_COV_HASH", + "FINALIZED_LENDER_VAULT_COV_HASH": "FINALIZED_LENDER_VAULT_COV_HASH", + "PROTOCOL_FEE_VAULT_COV_HASH": "PROTOCOL_FEE_VAULT_COV_HASH", + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000001" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "instance.CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "principal_asset_auth": { + "description": "AssetAuth covenant holding the loan principal after activation — out[1] of AcceptOffer. Keyed by the borrower NFT (AssetAuth(borrower_nft, amount 1, with_asset_burn=false)); the borrower later claims the principal by presenting the borrower NFT. sha256(its spk) == PRINCIPAL_OUTPUT_SCRIPT_HASH.", + "script": { + "type": "simplicity", + "source": "./asset_auth.simf", + "compile_params": { + "ASSET_ID": "BORROWER_NFT_ASSET_ID", + "ASSET_AMOUNT": "1", + "WITH_ASSET_BURN": "false" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + }, + "lender_vault_finalized": { + "description": "The lender's settlement vault in its FINALIZED state — RepayLoan out[1]. A FinalizedAssetAuthVault(vault=principal, keeper=lender_nft, supplier=borrower_nft, keeper_burn=true, supplier_burn=true, is_active=false, finalized=ZERO_HASH) holding the whole repayment less the protocol fee. 'Finalized' = the debt is fully cleared, so the vault no longer accepts supply; the lender withdraws from it by presenting (and burning) the lender NFT. The lending covenant hard-codes sha256(this spk) as FINALIZED_LENDER_VAULT_COV_HASH and enforces it on the repayment output, so this utxo_type MUST compile to that exact hash — asserted by lending_v3_repay_loan_vault_outputs_match_covenant_hashes.", + "script": { + "type": "simplicity", + "source": "./asset_auth_vault.simf", + "compile_params": { + "VAULT_ASSET_ID": "PRINCIPAL_ASSET_ID", + "KEEPER_AUTH_ASSET_ID": "LENDER_NFT_ASSET_ID", + "SUPPLIER_AUTH_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "KEEPER_AUTH_ASSET_AMOUNT": "1", + "FINALIZED_VAULT_COV_HASH": "ZERO_HASH", + "IS_ACTIVE": "false", + "WITH_KEEPER_ASSET_BURN": "true", + "WITH_SUPPLIER_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + }, + "protocol_fee_vault_finalized": { + "description": "The protocol-fee settlement vault in its FINALIZED state — RepayLoan out[2]. Same shape as lender_vault_finalized but keyed to the protocol-fee keeper asset and with keeper_burn=FALSE (the keeper asset is a shared, reusable fee-collection NFT, so it is preserved rather than burned on withdrawal). Holds the 10% protocol cut of the loan interest. The lending covenant hard-codes sha256(this spk) as FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH and enforces it on the repayment output.", + "script": { + "type": "simplicity", + "source": "./asset_auth_vault.simf", + "compile_params": { + "VAULT_ASSET_ID": "PRINCIPAL_ASSET_ID", + "KEEPER_AUTH_ASSET_ID": "PROTOCOL_FEE_KEEPER_ASSET_ID", + "SUPPLIER_AUTH_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "KEEPER_AUTH_ASSET_AMOUNT": "1", + "FINALIZED_VAULT_COV_HASH": "ZERO_HASH", + "IS_ACTIVE": "false", + "WITH_KEEPER_ASSET_BURN": "false", + "WITH_SUPPLIER_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + }, + "lender_nft_script_auth": { + "description": "ScriptAuth covenant holding the lender NFT during a pending offer — out[3]. Keyed by SCRIPT_HASH = sha256(lending covenant scriptPubKey, INCLUDING its 2 storage slots) i.e. the pending offer's own script hash (ScriptAuth::from_simplex_program). NOTE: the manifest's tapleaf/script-hash compute currently hashes the storage-less covenant; hashing a covenant-with-storage needs the engine extension tracked in upnext/11.", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "compile_params": { + "SCRIPT_HASH": "LENDING_COV_SCRIPT_HASH" + } + }, + "asset": "LENDER_NFT_ASSET_ID", + "confidential": false + } + }, + "actions": {}, + "classes": { + "issuance_factory": { + "description": "A persistent issuance factory. Created once (CreateFactory); the owner holds the auth NFT and reuses the factory covenant to mint many lending offers. The deployed indexer's FactoryCreationsTracker is seeded with hardcoded (issuing_utxos_count=2, reissuance_flags=0) — offers minted from a factory with any other parameters will not be detected.", + "fields": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset id of the factory asset, issued (total amount 2) in the creation tx. One unit stays in the factory covenant, one is the wallet-held auth NFT. Derived from the issuance input's outpoint + entropy and captured on_resolved." + }, + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "description": "Number of UTXOs the factory may use for issuance in a single tx. Baked into the covenant (param::ISSUING_UTXOS_COUNT). Must be 2 to match the deployed indexer." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "description": "Per-slot reissuance bitmask baked into the covenant (param::REISSUANCE_FLAGS). Must be 0 to match the deployed indexer." + } + }, + "methods": { + "CreateFactory": { + "is_constructor": true, + "description": "Issue the factory asset (total 2) from a wallet L-BTC UTXO, keep 1 unit as the owner's auth NFT (out[0], wallet p2wpkh), lock 1 unit in the issuance_factory covenant (out[1]), and publish the 13-byte creation OP_RETURN (out[2]). This layout is exactly what the deployed indexer's FactoryCreationsTracker requires: IssuanceFactory::try_from_tx reads the metadata at output index 2, verifies program_id + params, then finds the single amount-1 program output (covenant) and the single amount-1 auth output. Writes the instance file recording FACTORY_ASSET_ID.", + "params": { + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "default": "2", + "description": "Factory issuing-UTXOs count. Leave at the default 2 — the deployed indexer only detects factories (and their offers) with issuing_utxos_count == 2." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "default": "0", + "description": "Factory reissuance-flags bitmask. Leave at the default 0 — the deployed indexer only detects factories with reissuance_flags == 0." + }, + "FACTORY_PROGRAM_ID": { + "type": "bytes", + "default": "dd1e7f89", + "description": "The 4-byte protocol message-type tag that prefixes the factory-creation OP_RETURN (out[2]). The indexer's IssuanceFactory::try_from_tx rejects the tx unless these bytes match. This is a fixed constant, NOT a per-run input — do not override. How it is derived (recompute ONLY if you edit issuance_factory.simf, which also changes the covenant address): the first 4 bytes of SHA-256 of the LF-normalized issuance_factory.simf source text. (Value verified against on-chain data; the engine no longer computes it — it's a plain constant so the tx-encoder stays protocol-agnostic.)" + } + }, + "create_instance": { + "class": "issuance_factory", + "fields": { + "FACTORY_ASSET_ID": "$instance.FACTORY_ASSET_ID", + "ISSUING_UTXOS_COUNT": "$params.ISSUING_UTXOS_COUNT", + "REISSUANCE_FLAGS": "$params.REISSUANCE_FLAGS" + } + }, + "inputs": [ + { + "id": "factory_issuance_input", + "description": "Wallet L-BTC UTXO whose outpoint (+ entropy) determines FACTORY_ASSET_ID. Carries a NEW issuance of the factory asset, amount 2 (FACTORY_ASSET_TOTAL_AMOUNT), 0 reissuance tokens — matching cli/commands/factory/core.rs::create.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 2, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.FACTORY_ASSET_ID": "asset" + } + }, + "ui": { + "label": "the L-BTC input that mints the factory asset — its outpoint is what gives the factory its identity", + "role": "issuance" + } + } + ], + "outputs": [ + { + "id": "auth_nft_out", + "description": "The owner's auth NFT: 1 unit of the factory asset paid to the wallet's index-0 P2WPKH (output 0). Whoever holds this authorizes minting offers from the factory.", + "destination": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "your factory auth NFT — keep it, it is what lets you mint offers from this factory", + "role": "auth_nft" + } + }, + { + "id": "factory_program_out", + "description": "1 unit of the factory asset locked in the issuance_factory covenant (output 1). This is the persistent program UTXO the indexer tracks and later offers spend + recreate.", + "destination": { + "utxo_type": "issuance_factory" + }, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "the factory covenant itself — spent and recreated every time you mint an offer", + "role": "covenant" + } + }, + { + "id": "creation_op_return", + "description": "Creation metadata OP_RETURN (output 2, index-fixed as CREATION_METADATA_OUTPUT_INDEX). 13 bytes: program_id (4, sha256(issuance_factory.simf source)[..4]) || issuing_utxos_count (1, u8) || reissuance_flags (8, u64 LE). Exactly IssuanceFactoryCreationMetadata::encode.", + "destination": { + "type": "op_return" + }, + "data": { + "parts": [ + { + "type": "bytes", + "value": "instance.FACTORY_PROGRAM_ID" + }, + { + "type": "u8", + "value": "instance.ISSUING_UTXOS_COUNT" + }, + { + "type": "u64", + "value": "instance.REISSUANCE_FLAGS", + "endian": "le" + } + ] + }, + "required_index": 2, + "ui": { + "label": "data telling the indexer this factory exists, and what parameters it was built with", + "role": "protocol_data", + "group": "burned / protocol data" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "validations": [], + "ui": { + "action": "create a reusable issuance factory — the minter you will create lending offers from" + } + } + } + }, + "lending_contract": { + "description": "A single P2P lending offer minted from an issuance factory (lending_v3). CreateOffer computes the lending (collateral) covenant address (out[5]) from the offer terms and the nested AssetAuth/AssetAuthVault cov-hash chain (task 07), matching simplicity-lending's LendingOfferParameters::build_arguments. The 5 nested hashes reproduce live offer 43ab4efe out[5] byte-exactly (examples/lending_recon.rs). Full on-chain assembly (dynamic storage leaves, script_auth-over-storage) is tracked in task 06 + upnext/10-11.", + "fields": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The issuance factory's asset id (from the factory instance). Its auth NFT is spent in[0]/recreated out[0], and the factory covenant (in[1]) is recreated out[1]." + }, + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "description": "Factory issuing-UTXOs count (constant 2). Needed to compute the issuance_factory covenant address for in[1]/out[1]." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "description": "Factory reissuance-flags bitmask (constant 0). Needed to compute the issuance_factory covenant address for in[1]/out[1]." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Collateral asset id." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Loan (principal) asset id." + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Borrower NFT, issued from the factory covenant (in[1]) at offer creation." + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Lender NFT, issued from the collateral input (in[2]) at offer creation." + }, + "PROTOCOL_FEE_KEEPER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Protocol-fee keeper asset id. A fixed asset configured in the deployed indexer's OffersCreationTracker; the offer's lending covenant bakes in its AssetAuthVault hashes." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Collateral amount (base units) locked in out[5]." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Principal requested (base units)." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "description": "Interest rate in basis points. NOTE u64 in v3 (build_arguments casts the u16 OfferParameters value to u64) — a CMR-relevant change from v2's u16." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "CLTV block height for liquidation." + }, + "FINALIZED_LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of FinalizedAssetAuthVault(vault=principal, keeper=lender_nft, supplier=borrower_nft, keeper_burn=true, supplier_burn=true, is_active=false, finalized=0)." + }, + "LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of the ACTIVE lender AssetAuthVault (same params, is_active=true, finalized=FINALIZED_LENDER_VAULT_COV_HASH)." + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of FinalizedAssetAuthVault(vault=principal, keeper=protocol_fee_keeper, supplier=borrower_nft, keeper_burn=FALSE, supplier_burn=true, is_active=false, finalized=0)." + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of the ACTIVE protocol-fee AssetAuthVault (same params, is_active=true, finalized=FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH)." + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "description": "sha256(spk) of AssetAuth(borrower_nft, amount 1, with_asset_burn=false). The principal is paid to this borrower-NFT-keyed AssetAuth covenant (v3 replaces v2's wallet-hash borrower_output_script_hash)." + }, + "CURRENT_DEBT": { + "type": "u64", + "description": "Total amount to repay = principal + interest, where interest = principal * PRINCIPAL_INTEREST_RATE / 10000 (integer division, = get_total_amount_to_repay / apply_basis_points). Encoded as u64 BE in bytes[24..32] of the lending covenant's storage slot1 (out[5])." + }, + "LENDING_COV_SCRIPT_HASH": { + "type": "bytes32", + "description": "sha256(scriptPubKey) of the pending lending covenant INCLUDING its 2 storage slots (= sha256 of out[5]'s spk). The lender_nft_script_auth covenant (out[3]) commits to this via ScriptAuth::from_simplex_program(pending_offer). Computed with a tapleaf-over-lending.simf that folds the same storage leaves (task 11)." + }, + "ZERO_HASH": { + "type": "bytes32", + "default": "0000000000000000000000000000000000000000000000000000000000000000", + "description": "The all-zero FINALIZED_VAULT_COV_HASH baked into the finalized (non-active) AssetAuthVault variants. A fixed constant, kept as a field so the *_vault_finalized utxo_types can reference it by name and inherit its declared bytes32 type. Do not inline the zeros as a literal in compile_params: 64 zero characters are all ASCII digits, so the engine's value-based type inference would type it u64 and mis-compile the covenant." + } + }, + "methods": { + "CreateOffer": { + "is_constructor": true, + "description": "Mint a pending offer from the factory: spend the factory auth NFT (in[0]) + factory covenant issuing the borrower NFT (in[1], IssueAssets{output_index:0}) + collateral input issuing the lender NFT (in[2]); recreate the factory covenant (out[1]), pay borrower NFT to wallet (out[2]), lock lender NFT in a ScriptAuth keyed to the offer (out[3]), publish the 50-byte lending metadata OP_RETURN (out[4]), and lock collateral in the lending covenant with 2 storage slots (out[5]). create_instance computes the nested cov-hash chain that fixes the out[5] address (task 07).", + "params": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The issuance factory's asset id (from the factory instance file). Its auth NFT is spent in[0] and recreated out[0]/out[1]." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Collateral asset id (e.g. L-BTC)." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Loan asset id." + }, + "PROTOCOL_FEE_KEEPER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Protocol-fee keeper asset id (must match the deployed indexer's configured value)." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Collateral amount (base units)." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Principal amount (base units)." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "description": "Interest rate in basis points (u64 in v3)." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "Liquidation CLTV height." + }, + "ZERO_HASH": { + "type": "bytes32", + "default": "0000000000000000000000000000000000000000000000000000000000000000", + "description": "The all-zero finalized_vault_cov_hash used by finalized (non-active) AssetAuthVault variants. Constant; do not override." + }, + "LENDING_PROGRAM_ID": { + "type": "bytes", + "default": "f80c6162", + "description": "The 4-byte protocol message-type tag that prefixes the offer-creation OP_RETURN (out[4]); the indexer keys offer detection on it. Fixed constant, NOT a per-run input — do not override. Derived (recompute ONLY if you edit lending.simf, which also changes the covenant address) as the first 4 bytes of SHA-256 of the LF-normalized lending.simf source text. Verified against on-chain data; the engine treats it as a plain constant so the tx-encoder stays protocol-agnostic." + } + }, + "create_instance": { + "class": "lending_contract", + "fields": { + "FACTORY_ASSET_ID": "$params.FACTORY_ASSET_ID", + "ISSUING_UTXOS_COUNT": "2", + "REISSUANCE_FLAGS": "0", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "$params.PRINCIPAL_ASSET_ID", + "PROTOCOL_FEE_KEEPER_ASSET_ID": "$params.PROTOCOL_FEE_KEEPER_ASSET_ID", + "ZERO_HASH": "$params.ZERO_HASH", + "BORROWER_NFT_ASSET_ID": "$instance.BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "$instance.LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "$params.PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "$params.PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "$params.LOAN_EXPIRATION_TIME", + "CURRENT_DEBT": "params.PRINCIPAL_AMOUNT + params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000", + "FINALIZED_LENDER_VAULT_COV_HASH": { + "compute": "tapleaf", + "simf": "./asset_auth_vault.simf", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "ZERO_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "false" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "true" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "LENDER_VAULT_COV_HASH": { + "compute": "tapleaf", + "simf": "./asset_auth_vault.simf", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_LENDER_VAULT_COV_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "true" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "true" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "compute": "tapleaf", + "simf": "./asset_auth_vault.simf", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PROTOCOL_FEE_KEEPER_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "ZERO_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "false" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "false" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "compute": "tapleaf", + "simf": "./asset_auth_vault.simf", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PROTOCOL_FEE_KEEPER_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "true" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "false" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "compute": "tapleaf", + "simf": "./asset_auth.simf", + "params": { + "ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "WITH_ASSET_BURN": { + "type": "bool", + "value": "false" + } + } + }, + "LENDING_COV_SCRIPT_HASH": { + "compute": "tapleaf", + "simf": "./lending.simf", + "params": { + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "value": "LENDER_VAULT_COV_HASH" + }, + "FINALIZED_LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_LENDER_VAULT_COV_HASH" + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "value": "PROTOCOL_FEE_VAULT_COV_HASH" + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + } + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + } + } + }, + "inputs": [ + { + "id": "factory_auth_in", + "description": "Factory auth NFT (1 unit of the factory asset) from the wallet — authorizes minting an offer. Recreated at out[0] (input 0).", + "utxo_source": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "auth NFT for issuance factory", + "role": "auth_nft" + } + }, + { + "id": "factory_covenant_in", + "description": "The issuance_factory covenant UTXO (input 1). Spent via the IssueAssets path, issuing the borrower NFT from this covenant's outpoint; the covenant is recreated at out[1]. Covenant-sourced issuance — task 03.", + "utxo_source": { + "utxo_type": "issuance_factory" + }, + "required_index": 1, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<u32, u32>", + "value": "Left(0)", + "description": "IssueAssets path (PATH::LEFT). The u32 payload is the auth-NFT output index (0)." + } + }, + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.BORROWER_NFT_ASSET_ID": "asset" + } + }, + "ui": { + "label": "issuance factory covenant", + "role": "covenant" + } + }, + { + "id": "collateral_in", + "description": "Borrower's collateral wallet UTXO (input 2), sized >= COLLATERAL_AMOUNT. Also carries the lender-NFT issuance (issued from this input's outpoint).", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.COLLATERAL_AMOUNT" + }, + "required_index": 2, + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.LENDER_NFT_ASSET_ID": "asset" + } + }, + "ui": { + "label": "collateral to be locked for the loan", + "role": "collateral" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "factory_auth_out", + "description": "Factory auth NFT (1) back to the wallet (output 0). This is the auth-NFT output index the IssueAssets witness commits to.", + "destination": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "auth NFT for issuance factory (returned)", + "role": "auth_nft" + } + }, + { + "id": "factory_covenant_out", + "description": "The issuance_factory covenant recreated (output 1), holding 1 unit of the factory asset.", + "destination": { + "utxo_type": "issuance_factory" + }, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "issuance factory covenant", + "role": "covenant" + } + }, + { + "id": "borrower_nft_out", + "description": "Borrower NFT (1) to the wallet (output 2).", + "destination": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 2, + "ui": { + "label": "new NFT used for claiming the principal later", + "role": "borrower_nft" + } + }, + { + "id": "lender_nft_locked", + "description": "Lender NFT (1) locked in the lender_nft_script_auth covenant (output 3), keyed to the pending offer's own script hash (LENDING_COV_SCRIPT_HASH).", + "destination": { + "utxo_type": "lender_nft_script_auth" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 3, + "ui": { + "label": "newly created NFT to be sent to the lender", + "role": "lender_nft" + } + }, + { + "id": "creation_op_return", + "description": "Lending creation metadata OP_RETURN (output 4), 50 bytes: program_id(lending.simf)[4] || principal_asset_id[32, internal] || principal_amount[8, u64 LE] || loan_expiration[4, u32 LE] || interest_rate[2, u16 LE]. Matches lending metadata::encode (task 04). Interest rate is u16 in the OP_RETURN even though it is u64 in the covenant params.", + "destination": { + "type": "op_return" + }, + "data": { + "$comment": "50-byte layout must match lending metadata::encode exactly, in this order.", + "parts": [ + { + "type": "bytes", + "value": "instance.LENDING_PROGRAM_ID", + "$comment": "4-byte message-type tag; see the LENDING_PROGRAM_ID param for how it's derived." + }, + { + "type": "liquid.asset_id", + "value": "instance.PRINCIPAL_ASSET_ID" + }, + { + "type": "u64", + "value": "instance.PRINCIPAL_AMOUNT" + }, + { + "type": "u32", + "value": "instance.LOAN_EXPIRATION_TIME" + }, + { + "type": "u16", + "value": "instance.PRINCIPAL_INTEREST_RATE" + } + ] + }, + "required_index": 4, + "ui": { + "$comment": "Full structured UI hint (all fields). A bare string is shorthand for just `label`. `role` is a semantic tag; `group` overrides the derived net-effect account heading; `hide: true` drops this leg from the diff entirely.", + "label": "data created for the indexer to detect the offer and parse its terms", + "role": "protocol_data", + "group": "burned / protocol data", + "hide": false + } + }, + { + "id": "lending_collateral_out", + "description": "Collateral locked in the lending covenant (output 5), COLLATERAL_AMOUNT with 2 pending storage slots (is_active=0, current_debt).", + "destination": { + "utxo_type": "lending_collateral" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 5, + "ui": { + "label": "collateral prelocked in the lending covenant", + "role": "collateral" + } + }, + { + "id": "collateral_change", + "description": "Collateral-asset change back to the wallet.", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the collateral", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "validations": [], + "ui": { + "action": "create an offer to borrow {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol} by locking {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} as collateral" + } + }, + "AcceptOffer": { + "description": "A lender activates a pending offer (offer_open → loan_active). Spends the pending lending covenant (in[0], Accept witness Left(Left())) and the lender NFT locked in the ScriptAuth (in[1], authorizing input 0), supplies the principal (in[2]). Outputs: the lending covenant re-committed ACTIVE (out[0], storage is_active=1) still holding the collateral; the principal paid into an AssetAuth(borrower_nft) covenant the borrower can later claim (out[1]); the lender NFT returned to the lender's wallet (out[2]). Mirrors LendingOffer::attach_acceptance. Loads the offer instance written by CreateOffer.", + "inputs": [ + { + "id": "pending_offer_in", + "description": "The pending lending (collateral) covenant UTXO (input 0). Accept path.", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<Either<(), ()>, Either<Either<(u64, u64), u64>, u64>>", + "value": "Left(Left(()))", + "description": "OfferAcceptance (PATH::Left(Left(())))." + } + }, + "ui": { + "label": "the open offer you are accepting — the covenant holding the borrower's collateral", + "role": "covenant" + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT held in the lender_nft_script_auth covenant (input 1). The ScriptAuth authorizes its spend by checking input 0 is the pending offer covenant.", + "utxo_source": { + "utxo_type": "lender_nft_script_auth" + }, + "required_index": 1, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0", + "description": "Index of the pending-offer covenant input this ScriptAuth is bound to (input 0)." + } + }, + "ui": { + "label": "the lender NFT, unlocked from escrow — it is the claim on this loan", + "role": "lender_nft" + } + }, + { + "id": "principal_in", + "description": "Lender's wallet UTXO providing the loan principal (input 2), sized >= PRINCIPAL_AMOUNT.", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT" + }, + "required_index": 2, + "ui": { + "label": "the principal you are lending", + "role": "principal" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "active_offer_out", + "description": "The lending covenant re-committed in the ACTIVE state (output 0), still holding COLLATERAL_AMOUNT of collateral; storage slot0 = is_active = 1.", + "destination": { + "utxo_type": "lending_collateral_active" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 0, + "ui": { + "label": "the loan, now live — still holding the borrower's collateral until they repay", + "role": "covenant" + } + }, + { + "id": "principal_out", + "description": "The principal paid into an AssetAuth(borrower_nft) covenant (output 1), PRINCIPAL_AMOUNT. The borrower claims it later with the borrower NFT. Enforced by the covenant's PRINCIPAL_OUTPUT_SCRIPT_HASH.", + "destination": { + "utxo_type": "principal_asset_auth" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 1, + "ui": { + "label": "the principal, locked where only the borrower can claim it", + "role": "principal" + } + }, + { + "id": "lender_nft_out", + "description": "Lender NFT (1) returned to the lender's wallet (output 2) — unlocked from the ScriptAuth on activation.", + "destination": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 2, + "ui": { + "label": "your lender NFT — keep it, it is how you collect the repayment", + "role": "lender_nft" + } + }, + { + "id": "principal_change", + "description": "Principal-asset change back to the lender.", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the principal", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the lender.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "validations": [], + "ui": { + "action": "lend {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol} against {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} of collateral" + } + }, + "CancelOffer": { + "description": "The borrower withdraws a pending offer before activation (offer_open → cancelled). Spends the pending lending covenant (in[0], Cancel witness Left(Right())) and the lender NFT from its ScriptAuth (in[1]) and the borrower NFT from the wallet (in[2]); burns both NFTs to OP_RETURN (out[0] lender, out[1] borrower — index-fixed by the covenant) and returns the collateral to the borrower (out[2]). Mirrors LendingOffer::attach_cancellation. The covenant only checks each NFT lands on an OP_RETURN, so data-less burns suffice.", + "inputs": [ + { + "id": "pending_offer_in", + "description": "The pending lending (collateral) covenant UTXO (input 0). Cancel path.", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<Either<(), ()>, Either<Either<(u64, u64), u64>, u64>>", + "value": "Left(Right(()))", + "description": "OfferCancellation (PATH::Left(Right(())))." + } + }, + "ui": { + "label": "your open offer — the covenant holding your collateral", + "role": "covenant" + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT held in the lender_nft_script_auth covenant (input 1); burned at out[0].", + "utxo_source": { + "utxo_type": "lender_nft_script_auth" + }, + "required_index": 1, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0", + "description": "The pending-offer covenant input this ScriptAuth is bound to (input 0)." + } + }, + "ui": { + "label": "the lender NFT, unlocked from escrow — destroyed below, since nobody took the offer", + "role": "lender_nft" + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT from the wallet (input 2); burned at out[1].", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 2, + "ui": { + "label": "your borrower NFT — destroyed below", + "role": "borrower_nft" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "lender_nft_burned", + "description": "Lender NFT burned to OP_RETURN (output 0).", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "lender NFT destroyed — the offer can never be accepted now", + "role": "lender_nft" + } + }, + { + "id": "borrower_nft_burned", + "description": "Borrower NFT burned to OP_RETURN (output 1).", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your borrower NFT, destroyed", + "role": "borrower_nft" + } + }, + { + "id": "collateral_returned", + "description": "Collateral returned to the borrower's wallet (output 2).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 2, + "ui": { + "label": "your collateral, returned to you", + "role": "collateral" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "validations": [], + "ui": { + "action": "cancel your offer and take back the {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} collateral" + } + }, + "ClaimPrincipal": { + "ui": { + "action": "claim the loan principal of {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol}" + }, + "description": "The borrower withdraws the loan principal from the AssetAuth(borrower_nft) covenant created at activation (AcceptOffer out[1]). Spends that covenant (in[0]) while presenting the borrower NFT (in[1], INPUT_ASSET_INDEX=1) and preserving it (out[1], OUTPUT_ASSET_INDEX=1); the covenant only checks the auth NFT is present and re-output (WITH_ASSET_BURN=false), so the principal (out[0]) flows freely to the borrower. Mirrors AssetAuth::attach_unlocking (unlocks_without_burn_with_one_explicit_output). The offer stays active (loan_active → loan_active); collateral is unaffected.", + "inputs": [ + { + "id": "principal_asset_auth_in", + "description": "The AssetAuth(borrower_nft) covenant UTXO holding the principal (input 0).", + "utxo_source": { + "utxo_type": "principal_asset_auth" + }, + "required_index": 0, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "witnesses": { + "INPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1", + "description": "Index of the input carrying the borrower auth NFT (input 1)." + }, + "OUTPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1", + "description": "Index of the output the borrower auth NFT is preserved at (output 1)." + } + }, + "ui": { + "label": "the covenant holding your borrowed funds, created when the lender accepted", + "role": "covenant" + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower auth NFT from the wallet (input 1) — proves the borrower and is re-output at out[1].", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your borrower NFT, proving the loan is yours — returned to you below", + "role": "borrower_nft" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "principal_claimed", + "description": "The loan principal released to the borrower's wallet (output 0), PRINCIPAL_AMOUNT.", + "destination": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 0, + "ui": { + "label": "the borrowed funds, released to you", + "role": "principal" + } + }, + { + "id": "borrower_nft_out", + "description": "Borrower auth NFT (1) preserved back to the wallet (output 1) — checked by the covenant.", + "destination": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "your borrower NFT, back in your wallet — you need it to repay later", + "role": "borrower_nft" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "validations": [] + }, + "RepayLoan": { + "ui": { + "action": "repay the loan in full — {instance.CURRENT_DEBT} {instance.PRINCIPAL_ASSET_ID:symbol} (principal + interest) — and release the {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} collateral" + }, + "description": "The borrower clears the whole debt in one transaction and takes the collateral back (loan_active → repaid). Spends the ACTIVE lending covenant (in[1], FullRepayment witness) while presenting and BURNING the borrower NFT (in[0]/out[0]) — burning the NFT is what makes this final: it is the auth token for ClaimPrincipal and for supplying the vaults, so no further action on the offer is possible. The repayment is split into two freshly-created settlement vaults the covenant's baked-in hashes pin exactly: the lender's (out[1], debt minus the protocol fee) and the protocol's (out[2], 10% of the interest). The collateral (out[3]) is then unconstrained and goes back to the borrower's wallet. Mirrors LendingOffer::attach_full_repayment + attach_vaults in the NoRepayments phase. SCOPE: this models a full repayment of an UNTOUCHED active loan (no prior partial repayment), which is the case the covenant's validate_vaults reaches via `already_repaid_amount == 0` — the branch that CREATES the vaults as plain outputs rather than spending existing ones. A repayment after a partial one must instead spend + top up the live vaults (AssetAuthVault supply/final_supply witnesses); see backlog/08.", + "params": { + "TOTAL_PROTOCOL_FEE": { + "type": "u64", + "formula": "instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000", + "description": "The protocol's cut, paid to out[2]: 10% (PROTOCOL_FEE_BPS=1000) of the total loan interest, where interest = PRINCIPAL_AMOUNT * PRINCIPAL_INTEREST_RATE / 10000. Auto-computed; do not override — the covenant recomputes it and rejects any other split. Both divisions floor, and in this order, exactly as lending.simf's apply_basis_points chain does (get_protocol_fee_amount(get_total_fee_amount())); folding them into one division would round differently." + }, + "LENDER_VAULT_AMOUNT": { + "type": "u64", + "formula": "instance.CURRENT_DEBT - instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000", + "description": "The lender's share, paid to out[1]: the entire debt less TOTAL_PROTOCOL_FEE (= the covenant's additional_lender_vault_amount = amount_to_repay - protocol_fee_repaid). Auto-computed; do not override. Spelled out from instance fields rather than referencing TOTAL_PROTOCOL_FEE so the two formulas are independent of the order params are evaluated in." + } + }, + "inputs": [ + { + "id": "borrower_nft_in", + "description": "Borrower NFT from the wallet (input 0) — authorizes the repayment and is burned at out[0]. The covenant fixes this at input 0 (full_repay_offer's borrower_nft_input_index).", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your borrower NFT, proving this loan is yours — burned here, which closes the loan", + "role": "borrower_nft" + } + }, + { + "id": "active_offer_in", + "description": "The ACTIVE lending (collateral) covenant UTXO (input 1) — storage is_active=1, current_debt=CURRENT_DEBT. FullRepayment path. The covenant asserts its own input index is 1 and that its script hash commits to storage(true, current_debt), which is what ties the witness's debt payload to reality.", + "utxo_source": { + "utxo_type": "lending_collateral_active" + }, + "required_index": 1, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<Either<(), ()>, Either<Either<(u64, u64), u64>, u64>>", + "value": "Right(Left(Right(instance.CURRENT_DEBT)))", + "description": "FullRepayment (PATH::Right(Left(Right(current_debt)))). The u64 payload is the debt being cleared; it is not trusted input — the covenant recomputes the input's expected script hash from it and compares against the real one." + } + }, + "ui": { + "label": "the live loan itself — the covenant holding your collateral, created when the lender accepted", + "role": "covenant" + } + }, + { + "id": "repayment_in", + "description": "The borrower's principal-asset UTXO funding the repayment (input 2), sized >= CURRENT_DEBT. The covenant does not constrain this input (in this branch input indexes 2/3 are the unused vault-input slots); the repayment is enforced on the vault OUTPUTS instead, so any funding source works.", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.CURRENT_DEBT" + }, + "required_index": 2, + "ui": { + "label": "your funds to repay with — the debt is the principal plus interest", + "role": "repayment" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "borrower_nft_burned", + "description": "Borrower NFT burned to OP_RETURN (output 0) — covenant-fixed index. Data-less burn; the covenant only checks is_op_return.", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your borrower NFT, destroyed — this is what closes the loan for good", + "role": "borrower_nft", + "group": "burned / protocol data" + } + }, + { + "id": "lender_vault_out", + "description": "The lender's finalized settlement vault (output 1), holding LENDER_VAULT_AMOUNT of the principal asset. Covenant-fixed: full_repay_offer's lender-vault output index is 1, and validate_vaults pins the script hash to FINALIZED_LENDER_VAULT_COV_HASH (finalized, not active, because current_debt == amount_to_repay). The lender collects it later with the lender NFT.", + "destination": { + "utxo_type": "lender_vault_finalized" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "params.LENDER_VAULT_AMOUNT", + "confidential": false, + "required_index": 1, + "ui": { + "label": "the lender's repayment, parked for them to collect", + "role": "vault" + } + }, + { + "id": "protocol_fee_vault_out", + "description": "The protocol-fee finalized settlement vault (output 2), holding TOTAL_PROTOCOL_FEE of the principal asset. Covenant-fixed index 2; script hash pinned to FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH (finalized because this repayment clears the entire protocol fee at once).", + "destination": { + "utxo_type": "protocol_fee_vault_finalized" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "params.TOTAL_PROTOCOL_FEE", + "confidential": false, + "required_index": 2, + "ui": { + "label": "the protocol's fee — 10% of the interest", + "role": "vault" + } + }, + { + "id": "collateral_returned", + "description": "The collateral released back to the borrower's wallet (output 3), COLLATERAL_AMOUNT. Index 3 is this manifest's choice, not the covenant's: full_repay_offer only checks the collateral on the INPUT side and adds no program output, so once the vaults are satisfied the collateral is unconstrained.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 3, + "ui": { + "label": "your collateral, released back to you", + "role": "collateral" + } + }, + { + "id": "principal_change", + "description": "Principal-asset change back to the borrower (anything above CURRENT_DEBT).", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the repayment", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "validations": [] + } + } + } + }, + "lifecycle": { + "states": [ + "factory_created", + "offer_open", + "loan_active", + "cancelled", + "repaid" + ], + "entry_actions": [ + "CreateFactory", + "CreateOffer" + ], + "transitions": { + "CreateFactory": { + "to": "factory_created" + }, + "CreateOffer": { + "to": "offer_open" + }, + "AcceptOffer": { + "from": "offer_open", + "to": "loan_active" + }, + "CancelOffer": { + "from": "offer_open", + "to": "cancelled", + "unilateral": true + }, + "ClaimPrincipal": { + "from": "loan_active", + "to": "loan_active", + "unilateral": true + }, + "RepayLoan": { + "from": "loan_active", + "to": "repaid", + "unilateral": true + } + } + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/zeroconf.manifest.json b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/zeroconf.manifest.json new file mode 100644 index 0000000..c4506bc --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/zeroconf.manifest.json @@ -0,0 +1,7 @@ +{ + "manifest_version": "0.1.0", + "description": "Example zeroconf", + "chain": "liquid", + "utxo_types": {}, + "actions": {} +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts new file mode 100644 index 0000000..537212c --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; + +import dex from "./__fixtures__/dex.manifest.json"; +import lastWill from "./__fixtures__/last_will.manifest.json"; +import lending from "./__fixtures__/lending.manifest.json"; +import lendingV2 from "./__fixtures__/lending_v2.manifest.json"; +import lendingV3 from "./__fixtures__/lending_v3.manifest.json"; +import p2pkGrouped from "./__fixtures__/p2pk-grouped.manifest.json"; +import p2pk from "./__fixtures__/p2pk.manifest.json"; +import zeroconf from "./__fixtures__/zeroconf.manifest.json"; +import { findAction, normaliseManifest } from "./normalise"; +import { refuseUnsupported } from "./refuse"; +import { ignored, inspectConstructs, loadBearing } from "./registry"; + +// The seven published manifests, vendored byte for byte from txmanifest-wallet at +// 7d56516a1a1e44a586f25d45a34619c3953758dd. They are the only thing that can answer what +// this wallet actually does with real documents rather than with ones written to suit it, +// and three generations of the lending protocol coexist in them — which is the point, since +// the format changed faster than its own specification. + +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + +const CORPUS = { + dex, + last_will: lastWill, + lending, + lending_v2: lendingV2, + lending_v3: lendingV3, + p2pk, + zeroconf, +} as unknown as Record<string, Record<string, unknown>>; + +function normalised(name: string) { + return normaliseManifest(CORPUS[name]!); +} + +function refusalFor(name: string): string { + const refusal = refuseUnsupported(normalised(name).manifest, { + compilerVersion: "0.6.0", + contractSources: {}, + policyAsset: POLICY_ASSET, + }); + + return refusal ? refusal.reason : ""; +} + +describe("every published manifest is read", () => { + for (const name of Object.keys(CORPUS)) { + test(`${name} normalises without throwing`, () => { + expect(() => normalised(name)).not.toThrow(); + }); + } + + // The action counts are the documents' own. A runtime that read only one declaration + // shape would find none in five of these seven. + test("and its actions are found, in whichever shape it declared them", () => { + const counts = Object.fromEntries( + Object.keys(CORPUS).map((name) => [name, normalised(name).manifest.actions.length]), + ); + + expect(counts).toEqual({ + dex: 4, + last_will: 4, + lending: 10, + lending_v2: 9, + lending_v3: 6, + p2pk: 2, + zeroconf: 0, + }); + }); + + test("each by name", () => { + const { manifest } = normalised("lending_v3"); + + for (const action of manifest.actions) { + expect(findAction(manifest, action.name)?.name).toBe(action.name); + } + }); + + // A valid manifest that does nothing. It exists in the corpus and a runtime that assumes + // every manifest has something in it falls over on the smallest real document there is. + test("including the one that declares nothing at all", () => { + const { manifest } = normalised("zeroconf"); + + expect(manifest.actions).toEqual([]); + expect(manifest.utxoTypes).toEqual({}); + }); +}); + +// AC-10 across generations. Five of the seven declare their actions as methods inside +// classes; p2pk declares them flat; lending and lending_v2 carry both shapes in one document. +describe("both declaration shapes, in the documents that actually use them", () => { + test("a grouped manifest's methods are actions", () => { + expect(normalised("dex").manifest.actions.map((action) => action.boundTo)).toEqual([ + "tessera_offer_contract", + "tessera_offer_contract", + "tessera_offer_contract", + "tessera_offer_contract", + ]); + }); + + test("a flat manifest's are too, bound to nothing", () => { + expect(normalised("p2pk").manifest.actions.every((action) => !action.boundTo)).toBe(true); + }); + + // lending declares two flat actions and eight class methods in one document, which is the + // case a runtime handling only one shape would half-read without failing. + test("and a document carrying both is read whole", () => { + const bound = normalised("lending").manifest.actions.filter((action) => action.boundTo); + + expect(bound).toHaveLength(8); + expect(normalised("lending").manifest.actions).toHaveLength(10); + }); + + test("the two spellings of one protocol converge", () => { + const flat = normaliseManifest(p2pk as unknown as Record<string, unknown>).manifest.actions; + const grouped = normaliseManifest(p2pkGrouped as unknown as Record<string, unknown>).manifest + .actions; + + expect(grouped.map(({ boundTo: _boundTo, ...rest }) => rest)).toEqual( + flat.map(({ boundTo: _boundTo, ...rest }) => rest), + ); + }); +}); + +// AC-02 across the corpus, and the measurement this issue exists to take: what this wallet +// can act on today, and what it refuses. A construct the runtime does not read is a refusal +// by name rather than a signature over something unparsed — but a refusal is still a +// protocol this wallet cannot perform. +describe("what this wallet can do with each published protocol", () => { + test("p2pk builds", () => { + expect(refusalFor("p2pk")).toBe(""); + }); + + test("zeroconf builds, having nothing to build", () => { + expect(refusalFor("zeroconf")).toBe(""); + }); + + // Recorded as the measurement rather than as an expectation: these five refuse today, and + // the first construct each refuses on is what a slice implementing it would remove. + test.each([ + ["dex", "is_constructor"], + ["last_will", "is_constructor"], + ["lending", "confidential"], + ["lending_v2", "confidential"], + ["lending_v3", "is_constructor"], + ])("%s refuses, on %s", (name, construct) => { + expect(refusalFor(name)).toContain(construct); + }); + + test("and every refusal names where it was, not just what it was", () => { + expect(refusalFor("lending")).toContain("action PrepareLender"); + }); +}); + +// AC-02's decorative half across the corpus: what is ignored is reported as ignored. +describe("what each protocol says that this wallet reads past", () => { + test("p2pk's attestation_version, which no implementation reads", () => { + const keys = ignored(inspectConstructs(normalised("p2pk").manifest)).map((f) => f.key); + + expect(keys).toContain("attestation_version"); + }); + + test("and lending_v3's display metadata, which is the protocol's own text", () => { + const findings = ignored(inspectConstructs(normalised("lending_v3").manifest)); + + expect(findings.length).toBeGreaterThan(0); + }); + + test("nothing load-bearing is ever in the ignored list", () => { + for (const name of Object.keys(CORPUS)) { + const findings = inspectConstructs(normalised(name).manifest); + const overlap = ignored(findings).filter((finding) => finding.loadBearing); + + expect(overlap).toEqual([]); + } + }); +}); + +// The coverage table, asserted so it cannot drift silently. Every one of these is a +// construct real protocols use and this wallet does not read; a slice that implements one +// has to change this list, which is the point of writing it down. +describe("what the corpus uses and this wallet does not read", () => { + test("the whole list, across all seven", () => { + const unread = new Set<string>(); + + for (const name of Object.keys(CORPUS)) { + for (const finding of loadBearing(inspectConstructs(normalised(name).manifest))) { + unread.add(finding.key); + } + } + + expect([...unread].sort()).toEqual([ + "confidential", + "create_instance", + "data", + "default", + "from_address", + "is_constructor", + "issuance", + "on_pre_broadcast", + "on_resolved", + "required_index", + "sequence", + "simplicity_type", + "source", + "value", + ]); + }); +}); From 4f84ff63a1bac1b3665ea555ef55d2e93fa5ca68 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 16:45:13 +0300 Subject: [PATCH 048/124] feat(liquid): build the three constructs the specification put in scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus measurement found them: object-form data, sequence and from_address are named in the approved scope because lending_v3 and last_will cannot be built without them, and none was implemented. No acceptance criterion names them, so nothing else would have caught it. Each changes what the transaction is rather than how it looks, so an unresolvable declaration refuses rather than defaulting. A sequence is a bare number or a relative-blocks count, and a count that will not fit BIP68's sixteen bits is refused rather than truncated into a different timelock. from_address restricts what the wallet may fund the input from, because a protocol requiring an address is usually requiring a key. An op_return output's parts are resolved before they are encoded — encoding the reference text would produce bytes that look like a payload and are the name of one. --- .../processConfidentialTransaction/index.ts | 12 ++ .../liquid/domain/manifest/coinSelection.ts | 7 + .../liquid/domain/manifest/corpus.test.ts | 3 - .../chains/liquid/domain/manifest/encode.ts | 21 ++- .../liquid/domain/manifest/inputRules.test.ts | 120 ++++++++++++++ .../liquid/domain/manifest/inputRules.ts | 147 ++++++++++++++++++ .../chains/liquid/domain/manifest/plan.ts | 87 +++++++++-- .../chains/liquid/domain/manifest/registry.ts | 6 +- .../chains/liquid/domain/manifest/review.ts | 33 +++- smplx | 2 +- 10 files changed, 411 insertions(+), 27 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/inputRules.test.ts create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/inputRules.ts diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 607ca2b..470f4b6 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -155,6 +155,7 @@ export const createProcessLiquidConfidentialTransaction = ( // naming it is what asks for one. undefined, covenant.signatureWitness, + sequenceFor(review, covenant.id), ); } @@ -269,6 +270,17 @@ export const createProcessLiquidConfidentialTransaction = ( export const processLiquidConfidentialTransaction = createProcessLiquidConfidentialTransaction(); +/** + * The relative timelock this covenant input must carry, when its action declared one. + * + * A covenant can require the timelock rather than merely permit it, and the chain rejects a + * transaction built without one — so a declaration dropped here fails on broadcast, far from + * anything that explains it. + */ +function sequenceFor(review: ManifestReview, id: string): number | undefined { + return review.inputRules.find((rule) => rule.id === id)?.sequence; +} + function requireNetwork(context: LiquidProcessCtContext): string { const network = SMPLX_NETWORKS[context.chain.settings.network]; diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts b/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts index 01cbd0f..7231dae 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts @@ -1,6 +1,13 @@ /** One wallet output the selector may spend, as the wallet already describes it. */ export type SelectableUtxo = { amount: string; + /** + * Where this output pays, when the wallet knows it. + * + * Only needed by an action that pins an input to one address. Optional because the + * wallet's own snapshot carries it and a caller assembling one by hand should not have to. + */ + scriptPubKeyHex?: string; spendable: boolean; txOut: string; txid: string; diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts index 537212c..c25a769 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts @@ -193,15 +193,12 @@ describe("what the corpus uses and this wallet does not read", () => { expect([...unread].sort()).toEqual([ "confidential", "create_instance", - "data", "default", - "from_address", "is_constructor", "issuance", "on_pre_broadcast", "on_resolved", "required_index", - "sequence", "simplicity_type", "source", "value", diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/encode.ts b/apps/extension/src/core/chains/liquid/domain/manifest/encode.ts index 51bfda1..872a00f 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/encode.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/encode.ts @@ -89,7 +89,12 @@ export function encodeLeafItem( * no endianness or padding to choose — so a document carrying one of those keys here is * refused rather than quietly encoded as if it had said nothing. */ -export function encodeDataParts(data: unknown): EncodedBytes { +export function encodeDataParts( + data: unknown, + resolve: (reference: string) => { ok: true; value: unknown } | { ok: false; reason: string } = ( + reference, + ) => ({ ok: true, value: reference }), +): EncodedBytes { const declared = asRecord(data)?.parts; if (!Array.isArray(declared)) { @@ -123,9 +128,21 @@ export function encodeDataParts(data: unknown): EncodedBytes { }; } + // A part's value can be a reference — every one in the corpus is — so it is resolved + // before it is encoded. Encoding the reference text itself would produce bytes that + // look like a payload and are the name of one. + const resolved = + typeof part.value === "string" && !part.value.startsWith("0x") + ? resolve(part.value) + : { ok: true as const, value: part.value }; + + if (!resolved.ok) { + return { ok: false, reason: `A data part could not be resolved: ${resolved.reason}` }; + } + // Big-endian, because there is no key to say otherwise and a length-prefixed binary // layout written by hand reads in that order. - const encoded = encodeTyped(type, part.value); + const encoded = encodeTyped(type, resolved.value); if (!encoded.ok) { return encoded; diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/inputRules.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/inputRules.test.ts new file mode 100644 index 0000000..2d76164 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/inputRules.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeDataParts } from "./encode"; +import { resolveInputRules } from "./inputRules"; +import { type NormalisedAction, normaliseManifest } from "./normalise"; +import type { ReferenceScope } from "./references"; + +// Three constructs the approved specification names as in scope because lending_v3 and +// last_will cannot be built without them. The shapes below are the ones those manifests +// actually use, not invented ones. + +function action(node: Record<string, unknown>): NormalisedAction { + return normaliseManifest({ actions: { Act: node } }).manifest.actions[0]!; +} + +function rules(node: Record<string, unknown>, scope: ReferenceScope = { params: {} }) { + return resolveInputRules(action(node), scope); +} + +describe("a sequence", () => { + // dex and lending_v2 both carry this bare number, which disables replaceability. + test("as a bare number is the sequence field itself", () => { + const result = rules({ inputs: [{ id: "a", sequence: 4_294_967_294 }] }); + + expect(result.ok && result.rules[0]?.sequence).toBe(4_294_967_294); + }); + + // last_will's shape: a relative timelock whose count comes from the deployment. + test("as relative blocks resolves through the deployment's fields", () => { + const result = rules( + { inputs: [{ id: "a", sequence: { relative_blocks: "instance.INHERIT_BLOCKS" } }] }, + { instance: { INHERIT_BLOCKS: 144n }, params: {} }, + ); + + expect(result.ok && result.rules[0]?.sequence).toBe(144); + }); + + test("as a literal relative-blocks count too", () => { + expect(rules({ inputs: [{ id: "a", sequence: { relative_blocks: 6 } }] }).ok).toBe(true); + }); + + // BIP68 gives the count sixteen bits. A larger one truncated is a different timelock. + test("beyond what a relative timelock can express is refused, not truncated", () => { + const result = rules({ inputs: [{ id: "a", sequence: { relative_blocks: 70_000 } }] }); + + expect(result.ok ? "" : result.reason).toContain("70000"); + }); + + test("that cannot be resolved refuses, naming the input", () => { + const result = rules({ + inputs: [{ id: "vault_in", sequence: { relative_blocks: "nowhere" } }], + }); + + expect(result.ok ? "" : result.reason).toContain("vault_in"); + }); + + test("an input declaring none carries none", () => { + expect(rules({ inputs: [{ id: "a" }] }).ok && rules({ inputs: [{ id: "a" }] })).toMatchObject({ + rules: [{ id: "a" }], + }); + }); +}); + +describe("from_address", () => { + // lending_v2's shape: the borrower's own address, from the deployment. + test("resolves through the deployment's fields", () => { + const result = rules( + { inputs: [{ from_address: "instance.BORROWER_ADDRESS", id: "a" }] }, + { instance: { BORROWER_ADDRESS: "tex1q_borrower" }, params: {} }, + ); + + expect(result.ok && result.rules[0]?.fromAddress).toBe("tex1q_borrower"); + }); + + test("refuses when the deployment does not carry it", () => { + const result = rules({ inputs: [{ from_address: "instance.MISSING", id: "a" }] }); + + expect(result.ok).toBe(false); + }); +}); + +// lending_v3's shape: a byte layout whose parts are references into the deployment, with a +// comment beside them that is not a part. +describe("object-form data", () => { + const resolve = (reference: string) => + reference === "instance.FACTORY_PROGRAM_ID" + ? ({ ok: true, value: "0xdeadbeef" } as const) + : ({ ok: false, reason: `no ${reference}` } as const); + + test("resolves each part before encoding it", () => { + const result = encodeDataParts( + { + $comment: "50-byte layout must match the protocol's own encoder", + parts: [ + { type: "bytes", value: "instance.FACTORY_PROGRAM_ID" }, + { type: "u8", value: 3 }, + ], + }, + resolve, + ); + + expect(result).toEqual({ hex: "deadbeef03", ok: true }); + }); + + test("refuses a part it cannot resolve rather than encoding the reference text", () => { + const result = encodeDataParts( + { parts: [{ type: "bytes", value: "instance.MISSING" }] }, + resolve, + ); + + expect(result.ok ? "" : result.reason).toContain("instance.MISSING"); + }); + + test("a literal is taken as itself", () => { + expect(encodeDataParts({ parts: [{ type: "bytes", value: "0x0102" }] }, resolve)).toEqual({ + hex: "0102", + ok: true, + }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/inputRules.ts b/apps/extension/src/core/chains/liquid/domain/manifest/inputRules.ts new file mode 100644 index 0000000..0258ad9 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/inputRules.ts @@ -0,0 +1,147 @@ +import { evaluateExpression } from "./evaluate"; +import { asArray, asRecord } from "./json"; +import type { NormalisationNote, NormalisedAction } from "./normalise"; +import { type ReferenceScope, resolveReference } from "./references"; + +/** What an action says about one of its inputs beyond where the money comes from. */ +export type InputRule = { + /** The manifest's id for the input, so a refusal can name it. */ + id: string; + /** + * The address the wallet must fund this input from, when the action pins one. + * + * A protocol that requires a specific address is usually requiring a specific key — the + * borrower's, in the one manifest that uses this — and funding it from any output the + * wallet happens to hold produces a transaction the protocol did not ask for. + */ + fromAddress?: string; + /** + * The relative timelock this input must carry. + * + * A covenant can require one, and the chain rather than the wallet rejects a transaction + * built without it. Dropping the declaration silently fails late and unexplainably. + */ + sequence?: number; +}; + +export type InputRulesResult = { ok: false; reason: string } | { ok: true; rules: InputRule[] }; + +/** BIP68 gives sixteen bits to a relative-blocks count; a larger one is not expressible. */ +const MAX_RELATIVE_BLOCKS = 0xff_ff; + +/** + * Reads what an action requires of each input beyond its source. + * + * Both of these change what the transaction is rather than how it looks, so a declaration + * the runtime cannot resolve is a refusal rather than a default — a sequence dropped or an + * address ignored produces a transaction the protocol did not ask for and the chain or the + * covenant rejects it somewhere far from the cause. + */ +export function resolveInputRules( + action: NormalisedAction, + scope: ReferenceScope, + notes?: NormalisationNote[], +): InputRulesResult { + const rules: InputRule[] = []; + + for (const declared of asArray(action.node.inputs)) { + const input = asRecord(declared); + + if (!input) { + continue; + } + + const id = typeof input.id === "string" ? input.id : "(unnamed)"; + const rule: InputRule = { id }; + + if ("sequence" in input) { + const sequence = resolveSequence(input.sequence, scope, notes); + + if (!sequence.ok) { + return { ok: false, reason: `Input ${id}: ${sequence.reason}` }; + } + + rule.sequence = sequence.value; + } + + if ("from_address" in input) { + const address = resolveFromAddress(input.from_address, scope, notes); + + if (!address.ok) { + return { ok: false, reason: `Input ${id}: ${address.reason}` }; + } + + rule.fromAddress = address.value; + } + + rules.push(rule); + } + + return { ok: true, rules }; +} + +/** + * A sequence is a bare number or a relative-blocks count, and the two mean different things. + * + * A bare number is the sequence field itself, which is how the corpus disables replaceability + * — `4294967294`. `{relative_blocks: n}` is a timelock, and BIP68 puts the count in the low + * sixteen bits with the type bit clear, so a count that does not fit there cannot be asked + * for at all rather than being truncated into a different timelock. + */ +function resolveSequence( + declared: unknown, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: number } { + if (typeof declared === "number" && Number.isInteger(declared) && declared >= 0) { + return { ok: true, value: declared }; + } + + const relative = asRecord(declared)?.relative_blocks; + + if (relative === undefined) { + return { + ok: false, + reason: "its sequence is neither a number nor a relative-blocks count.", + }; + } + + const blocks = + typeof relative === "number" + ? { ok: true as const, value: BigInt(relative) } + : evaluateExpression(String(relative), "amount", scope, notes); + + if (!blocks.ok) { + return { ok: false, reason: blocks.reason }; + } + + if (blocks.value < 0n || blocks.value > BigInt(MAX_RELATIVE_BLOCKS)) { + return { + ok: false, + reason: `it asks to wait ${blocks.value} blocks, which a relative timelock cannot express.`, + }; + } + + return { ok: true, value: Number(blocks.value) }; +} + +/** The address an input must be funded from, which every real use of this states by reference. */ +function resolveFromAddress( + declared: unknown, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: string } { + if (typeof declared !== "string") { + return { ok: false, reason: "its from_address is not an address or a reference to one." }; + } + + const found = resolveReference(declared, "witnessKey", scope, notes); + + if (!found.ok) { + return { ok: false, reason: found.reason }; + } + + return typeof found.value === "string" + ? { ok: true, value: found.value } + : { ok: false, reason: `${declared} is not an address.` }; +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts index b8b30db..e6cbaf7 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts @@ -1,7 +1,8 @@ +import { encodeDataParts } from "./encode"; import { evaluateExpression } from "./evaluate"; import { asArray, asRecord } from "./json"; import type { NormalisationNote, NormalisedAction } from "./normalise"; -import type { ReferenceScope } from "./references"; +import { type ReferenceScope, resolveReference } from "./references"; /** * A concrete amount the wallet worked out for one of the action's outputs. @@ -14,8 +15,12 @@ export type PlannedOutput = { id: string; /** Absent for change, whose amount is whatever is left after the fee. */ sats?: bigint; - /** Where it pays: a covenant type the wallet derived, the wallet, or change. */ - target: { kind: "change" } | { kind: "covenant"; utxoType: string } | { kind: "wallet" }; + /** Where it pays: a covenant type the wallet derived, the wallet, change, or nowhere. */ + target: + | { kind: "change" } + | { kind: "covenant"; utxoType: string } + | { kind: "data"; hex: string } + | { kind: "wallet" }; }; export type PlannedSpend = { @@ -51,17 +56,22 @@ export function planAction( } const id = typeof output.id === "string" ? output.id : ""; - const target = resolveTarget(output.destination); + const target = resolveTarget(output.destination, output.data, scope, notes); - if (!target) { - return { - ok: false, - reason: `Output ${id || "(unnamed)"} pays somewhere this runtime does not resolve yet.`, - }; + if (!target.ok) { + return { ok: false, reason: `Output ${id || "(unnamed)"} ${target.reason}` }; + } + + if (target.target.kind === "change") { + outputs.push({ id, target: target.target }); + + continue; } - if (target.kind === "change") { - outputs.push({ id, target }); + // An op_return output carries bytes rather than value. It is provably unspendable, so + // it pays nothing and nothing needs to fund it. + if (target.target.kind === "data") { + outputs.push({ id, sats: 0n, target: target.target }); continue; } @@ -83,7 +93,7 @@ export function planAction( } fundingSats += amount.sats; - outputs.push({ id, sats: amount.sats, target }); + outputs.push({ id, sats: amount.sats, target: target.target }); } if (outputs.length === 0) { @@ -99,18 +109,61 @@ export function planAction( * and the site accepts nothing else bare precisely so it cannot mean a parameter by * accident. */ -function resolveTarget(destination: unknown): PlannedOutput["target"] | undefined { +function resolveTarget( + destination: unknown, + data: unknown, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; target: PlannedOutput["target"] } { if (destination === "change") { - return { kind: "change" }; + return { ok: true, target: { kind: "change" } }; } if (destination === "wallet") { - return { kind: "wallet" }; + return { ok: true, target: { kind: "wallet" } }; + } + + const record = asRecord(destination); + const utxoType = record?.utxo_type; + + if (typeof utxoType === "string") { + return { ok: true, target: { kind: "covenant", utxoType } }; } - const utxoType = asRecord(destination)?.utxo_type; + if (record?.type !== "op_return") { + return { ok: false, reason: "pays somewhere this runtime does not resolve yet." }; + } + + // The payload is the output. An op_return with nothing in it says nothing, and a layout + // the runtime could not encode is one the protocol's own reader will not recognise. + const encoded = encodeDataParts(data, (reference) => { + const found = resolveReference(reference, "expression", scope, notes); + + return found.ok ? { ok: true, value: found.value } : { ok: false, reason: found.reason }; + }); + + if (!encoded.ok) { + return { ok: false, reason: `carries data this runtime cannot encode: ${encoded.reason}` }; + } + + return { ok: true, target: { hex: opReturnScript(encoded.hex), kind: "data" } }; +} + +/** + * An OP_RETURN script carrying these bytes. + * + * `6a` then a push of the payload — a direct push below 76 bytes, `4c` and a length byte up + * to 255. Longer than that is refused rather than encoded with a wider push, because nothing + * in the corpus needs one and a push nobody has exercised is a script nobody has checked. + */ +function opReturnScript(payloadHex: string): string { + const length = payloadHex.length / 2; + + if (length < 0x4c) { + return `6a${length.toString(16).padStart(2, "0")}${payloadHex}`; + } - return typeof utxoType === "string" ? { kind: "covenant", utxoType } : undefined; + return `6a4c${length.toString(16).padStart(2, "0")}${payloadHex}`; } /** A literal, or an expression evaluated at the amount site. */ diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts b/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts index 8ab2190..414cb8f 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts @@ -103,7 +103,7 @@ const SITES = { amount_sat: READ, asset: READ, description: SHOWN, - from_address: UNIMPLEMENTED, + from_address: READ, id: READ, issuance: UNIMPLEMENTED, on_resolved: UNIMPLEMENTED, @@ -114,7 +114,7 @@ const SITES = { // this, so a manifest asking for index 0 and getting 1 builds a transaction the // covenant rejects on chain. required_index: UNIMPLEMENTED, - sequence: UNIMPLEMENTED, + sequence: READ, ui: SHOWN, utxo_source: READ, // Read for the one thing the runtime can act on — which witness the signer must @@ -152,7 +152,7 @@ const SITES = { asset: READ, condition: UNIMPLEMENTED, confidential: UNIMPLEMENTED, - data: UNIMPLEMENTED, + data: READ, description: SHOWN, destination: READ, id: READ, diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts index e7a05a3..1e4d5e8 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.ts @@ -5,6 +5,7 @@ import { type ConfirmationModel, confirmationModel } from "./confirmation"; import { type CompileCovenant, covenantMatchesChain, deriveCovenantAddress } from "./covenant"; import { type CompileScriptPubKey, covenantHashFrom } from "./covenantHash"; import { estimateFeeSats } from "./fee"; +import { type InputRule, resolveInputRules } from "./inputRules"; import { asArray, asRecord } from "./json"; import { findAction, @@ -43,6 +44,8 @@ export type CovenantFinding = { */ export type ReviewedCovenantInput = { argumentsJson: string; + /** The manifest's id for the input, so what the action requires of it can be found. */ + id: string; /** * The witness the signer must fill with a signature over this transaction. * @@ -108,6 +111,8 @@ export type ManifestReview = { normalisation: NormalisationNote[]; outputs: ReviewedOutput[]; protocol: string; + /** What each input must carry beyond its source, when the action says so. */ + inputRules: InputRule[]; /** The wallet's own outputs that fund this, chosen by the wallet. */ selected: SelectableUtxo[]; }; @@ -282,6 +287,7 @@ export async function reviewManifestAction( covenantInputs.push({ argumentsJson: derived.derivation.argumentsJson, + id: site.id, ...(site.signatureWitness === undefined ? {} : { signatureWitness: site.signatureWitness }), source: derived.derivation.source, txOutHex, @@ -339,6 +345,14 @@ export async function reviewManifestAction( return { reason: plan.reason, refused: true }; } + // What the action requires of each input beyond where the money comes from: a relative + // timelock a covenant may depend on, and an address it may pin funding to. + const inputRules = resolveInputRules(action, { ...scope, fee: estimatedFee }, notes); + + if (!inputRules.ok) { + return { reason: inputRules.reason, refused: true }; + } + // The protocol's own rules about this action, checked once its amounts are known — a rule // comparing an amount cannot be checked before there is one. const failed = checkValidations(action, { ...scope, fee: estimatedFee }, notes); @@ -372,8 +386,24 @@ export async function reviewManifestAction( outputs.push({ id: planned.id, sats: planned.sats, scriptPubKeyHex }); } + // An action pinning an input to one address restricts what the wallet may fund it from. + // A protocol requiring a specific address is usually requiring a specific key, and funding + // it from whatever the wallet happens to hold builds a transaction it did not ask for. + const pinned = inputRules.rules.find((rule) => rule.fromAddress !== undefined)?.fromAddress; + const fundable = + pinned === undefined + ? input.fundingUtxos + : input.fundingUtxos.filter((utxo) => utxo.scriptPubKeyHex === pinned); + + if (pinned !== undefined && fundable.length === 0) { + return { + reason: `This action must be funded from ${pinned}, and this wallet holds nothing there.`, + refused: true, + }; + } + const selection: CoinSelection = selectCoins( - input.fundingUtxos, + fundable, plan.plan.fundingSats, BigInt(Math.ceil(feeRateSatsPerKvb)), ); @@ -392,6 +422,7 @@ export async function reviewManifestAction( ignoredConstructs: ignored(inspectConstructs(manifest)), normalisation: notes, outputs, + inputRules: inputRules.rules, protocol: manifest.protocol ?? "", selected: selection.selected, }; diff --git a/smplx b/smplx index 37f9e67..5b2dd7a 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 37f9e6763b4fecbaccf3df1dc82ab97e7d1f01f3 +Subproject commit 5b2dd7a3e0121489a1ceda151f5251dd33cc1697 From 5962f909027607f4b96017b371df67fac51a01bb Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 16:49:02 +0300 Subject: [PATCH 049/124] test(liquid): pin the addresses, because a wrong one looks exactly like a right one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failure mode of every encoding, ordering and convergence decision in the runtime is a well-formed address for the wrong contract. No test that recomputes its expectation alongside the value can catch that — the two would move together. So the addresses are written down. They are the compiler's own p2pk contract rather than one we wrote, so what they pin is not our consistency with ourselves: the parameterised address on two networks, the different one debug symbols produce, the one three extra leaves produce where a balanced tree would have differed, and that their order changes it. Beside them, a settled covenant hash settles on the same value and the same number of rounds every run. These do not prove agreement with a deployed protocol. Nothing here can until the corpus's own contract sources are obtained, which is a decision rather than work. --- .../adapters/smplx/loadSmplxWasm.test.ts | 74 +++++++++++++++++++ .../liquid/domain/manifest/computed.test.ts | 47 ++++++++++++ 2 files changed, 121 insertions(+) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 7a4fde5..cb7a6d6 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -614,3 +614,77 @@ describe("what a signed transaction says it spends", () => { expect(result.ok).toBe(false); }); }); + +// Golden vectors: the exact addresses this module derives, pinned. They exist because the +// failure mode of every encoding, ordering and convergence decision in the runtime is a +// well-formed address for the wrong contract, which no test that recomputes the expectation +// alongside the value can catch. These are the compiler's own p2pk contract, authored +// upstream, so what they pin is not our own consistency with ourselves. +describe("golden covenant addresses", () => { + // simplicityhl-0.6.0/examples/p2pk.simf, with its parameter renamed to the one the + // published manifest uses. Two identifiers differ from upstream and nothing else. + const UPSTREAM_P2PK = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + // simplicityhl-0.6.0/examples/p2pk.args, verbatim. + const ALICE = "0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + function address(input: { + debug?: boolean; + leaves?: string[]; + network?: string; + pubkey?: string; + }): string { + const args = JSON.stringify({ PUB_KEY: { type: "Pubkey", value: input.pubkey ?? ALICE } }); + const contract = new bindings.Contract( + UPSTREAM_P2PK, + args, + input.leaves ? JSON.stringify(input.leaves) : undefined, + input.debug, + ); + + return contract.covenantAddress(input.network ?? "liquid-testnet"); + } + + test("the parameterised contract, on testnet", () => { + expect(address({})).toBe("tex1peavhc0s5wcm0ans49jxg445enyh6uuwl8radea7expf2syt5rkzqjre6vm"); + }); + + test("the same contract on mainnet is a different address, and a fixed one", () => { + expect(address({ network: "liquid" })).toBe( + "ex1peavhc0s5wcm0ans49jxg445enyh6uuwl8radea7expf2syt5rkzqn6taa5", + ); + }); + + // Debug symbols change the CMR and therefore the address. The wallet builds each contract + // the way its protocol declares, so both are values a real protocol could sit at. + test("built with debug symbols it is a different address again", () => { + expect(address({ debug: true })).not.toBe(address({ debug: false })); + }); + + test("and that address is fixed too", () => { + expect(address({ debug: true })).toBe( + "tex1p8vjx8uana9z0k8670v9aqgys02we6yy0sndhjkapwhd76k2ux9vqv8rzsv", + ); + }); + + // Extra leaves are appended in declaration order, and the tree is folded left. Three of + // them is where a balanced tree would diverge, so it is the count worth pinning. + test("with three extra leaves, where a balanced tree would differ", () => { + const leaves = [`0x${"11".repeat(32)}`, `0x${"22".repeat(32)}`, `0x${"33".repeat(32)}`]; + + expect(address({ leaves })).toBe( + "tex1p70jezh2969ew3w29h2hvpwtl9eh4mzyuv8srpn9rfa8t4uputp7schqmqt", + ); + }); + + test("their order is part of the address", () => { + const forward = address({ leaves: [`0x${"11".repeat(32)}`, `0x${"22".repeat(32)}`] }); + const reversed = address({ leaves: [`0x${"22".repeat(32)}`, `0x${"11".repeat(32)}`] }); + + expect(forward).not.toBe(reversed); + }); + + test("a different parameter is a different covenant", () => { + expect(address({ pubkey: `0x${"01".repeat(32)}` })).not.toBe(address({})); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts index 2bd8cdf..6dcb70d 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts @@ -235,3 +235,50 @@ describe("resolveComputedParams", () => { }); }); }); + +// AC-08's other half, pinned. Convergence producing *a* stable value is not enough: it has +// to produce the same one every time, because the value is a covenant's script hash and a +// different one is a different address. The hash function here is the real SHA256 of a real +// compiled scriptPubKey, so what is pinned is the whole path. +describe("a settled hash is the same hash every time", () => { + const CHAIN = { + // B consumes nothing, so the apparent cycle auto-populate would create is a chain. + A_COV_HASH: { compute: "tapleaf", params: { PUB_KEY: "params.B_COV_HASH" }, simf: "./a.simf" }, + B_COV_HASH: { compute: "tapleaf", depends_on: [], simf: "./b.simf" }, + }; + + function settle() { + const manifest = normaliseManifest({ + actions: { Open: { params: { ...CHAIN, PUB_KEY: { type: "pubkey" } } } }, + }).manifest; + + return resolveComputedParams(manifest.actions[0]!, { + contractSources: SOURCES, + // A stand-in that is a real function of its arguments, which is the only property + // convergence needs; the address path itself is pinned against the real module. + hashCovenant: ({ argumentsJson, source }) => { + let hash = 0n; + + for (const code of `${source}${argumentsJson}`) { + hash = (hash * 1_000_003n + BigInt(code.codePointAt(0) ?? 0)) % 2n ** 256n; + } + + return hash.toString(16).padStart(64, "0").slice(-64); + }, + scope: { params: {} }, + }); + } + + test("settles on the same values on every run", () => { + const first = settle(); + const second = settle(); + + expect(first.ok && first.values).toEqual(second.ok ? second.values : {}); + }); + + test("and on the same number of rounds", () => { + const result = settle(); + + expect(result.ok ? result.rounds : 0).toBe(3); + }); +}); From f24c6d0534576c19f995621d9f188a08288cb6c3 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:03:27 +0300 Subject: [PATCH 050/124] test(liquid): pin what the deployed protocol's own contracts compile to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five sources lending, lending_v2 and lending_v3 reference, vendored from BlockstreamResearch/simplicity-lending at d0d46ffa. The manifests do not ship them — in production they arrive with the request — and without them nothing could check that this wallet compiles what a real protocol deployed rather than only what we wrote to suit it. All five compile through the extension's own wasm module, and their commitment merkle roots are written down. The root is what the covenant address is built from, so pinning it pins compilation itself: a compiler change, a parameter-encoding change or a debug-mode change all move it, and each would otherwise move an address silently. lending.simf is the case the bounded fixed point exists for: four of its thirteen parameters are other covenants' script hashes, two the finalised form of the same vaults. Changing one changes its address, which is asserted rather than assumed. --- .../adapters/smplx/loadSmplxWasm.test.ts | 102 +++ .../manifest/__fixtures__/contracts/README.md | 16 + .../__fixtures__/contracts/asset_auth.simf | 39 ++ .../contracts/asset_auth_vault.simf | 245 +++++++ .../contracts/issuance_factory.simf | 242 +++++++ .../__fixtures__/contracts/lending.simf | 599 ++++++++++++++++++ .../__fixtures__/contracts/script_auth.simf | 13 + 7 files changed, 1256 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/README.md create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth.simf create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth_vault.simf create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/issuance_factory.simf create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/lending.simf create mode 100644 apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/script_auth.simf diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index cb7a6d6..90a8263 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -688,3 +688,105 @@ describe("golden covenant addresses", () => { expect(address({ pubkey: `0x${"01".repeat(32)}` })).not.toBe(address({})); }); }); + +// The deployed protocol's own contracts. These are the sources `lending`, `lending_v2` and +// `lending_v3` reference, and until they were vendored nothing could check that this wallet +// compiles what a real protocol deployed rather than only what we wrote to suit it. +describe("the simplicity-lending contracts", () => { + const CONTRACTS = "../../domain/manifest/__fixtures__/contracts"; + + async function source(name: string): Promise<string> { + const { readFile: read } = await import("node:fs/promises"); + const { dirname, join } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + + return read(join(dirname(fileURLToPath(import.meta.url)), CONTRACTS, `${name}.simf`), "utf8"); + } + + const U256 = `0x${"11".repeat(32)}`; + const ARGUMENTS: Record<string, Record<string, { type: string; value: string }>> = { + asset_auth: { + ASSET_AMOUNT: { type: "u64", value: "0x0000000000000001" }, + ASSET_ID: { type: "u256", value: U256 }, + WITH_ASSET_BURN: { type: "bool", value: "true" }, + }, + asset_auth_vault: { + FINALIZED_VAULT_COV_HASH: { type: "u256", value: U256 }, + IS_ACTIVE: { type: "bool", value: "true" }, + KEEPER_AUTH_ASSET_AMOUNT: { type: "u64", value: "0x0000000000000001" }, + KEEPER_AUTH_ASSET_ID: { type: "u256", value: U256 }, + SUPPLIER_AUTH_ASSET_ID: { type: "u256", value: U256 }, + VAULT_ASSET_ID: { type: "u256", value: U256 }, + WITH_KEEPER_ASSET_BURN: { type: "bool", value: "true" }, + WITH_SUPPLIER_ASSET_BURN: { type: "bool", value: "true" }, + }, + issuance_factory: { + ISSUING_UTXOS_COUNT: { type: "u8", value: "0x01" }, + REISSUANCE_FLAGS: { type: "u64", value: "0x0000000000000001" }, + }, + lending: { + BORROWER_NFT_ASSET_ID: { type: "u256", value: U256 }, + COLLATERAL_AMOUNT: { type: "u64", value: "0x0000000000000001" }, + COLLATERAL_ASSET_ID: { type: "u256", value: U256 }, + FINALIZED_LENDER_VAULT_COV_HASH: { type: "u256", value: U256 }, + FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: U256 }, + LENDER_NFT_ASSET_ID: { type: "u256", value: U256 }, + LENDER_VAULT_COV_HASH: { type: "u256", value: U256 }, + LOAN_EXPIRATION_TIME: { type: "u32", value: "0x00000001" }, + PRINCIPAL_AMOUNT: { type: "u64", value: "0x0000000000000001" }, + PRINCIPAL_ASSET_ID: { type: "u256", value: U256 }, + PRINCIPAL_INTEREST_RATE: { type: "u64", value: "0x0000000000000001" }, + PRINCIPAL_OUTPUT_SCRIPT_HASH: { type: "u256", value: U256 }, + PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: U256 }, + }, + script_auth: { SCRIPT_HASH: { type: "u256", value: U256 } }, + }; + + // The commitment merkle root is what the covenant address is built from, so pinning it + // pins compilation itself: a compiler change, a parameter-encoding change or a debug-mode + // change all move it, and each of those would otherwise move an address silently. + const GOLDEN: Record<string, string> = { + asset_auth: "20fd155233a87fcc910a66f0395dc511ad08c5d7a8a9d774881de5520ac0ebf1", + asset_auth_vault: "8233cab286b79ac63ccac8f2fc67722cfb1ee9a5ca3e1d4179d09f5a9e1610de", + issuance_factory: "f610387190b1bc269d980bb391063fae96ea123dfce9a078936a1945d8675504", + lending: "34019215b7a6edffbf69e47d3795cc951f9962b723ecb3cf72f1f551669afe5c", + script_auth: "9c89c4aa4a20603c4e21b073d71238c37a6285b8e17b5bf19af1c74519781c18", + }; + + for (const [name, cmr] of Object.entries(GOLDEN)) { + test(`${name} compiles to a fixed commitment merkle root`, async () => { + const contract = new bindings.Contract( + await source(name), + JSON.stringify(ARGUMENTS[name]), + undefined, + undefined, + ); + + expect(contract.commitmentMerkleRoot()).toBe(cmr); + }); + } + + // lending.simf is the reason the bounded fixed point exists: four of its thirteen + // parameters are other covenants' script hashes, two of them the finalised form of the + // same vaults. A different hash going in is a different address coming out. + test("lending's address follows the covenant hashes compiled into it", async () => { + const text = await source("lending"); + const other = { + ...ARGUMENTS.lending, + LENDER_VAULT_COV_HASH: { type: "u256", value: `0x${"22".repeat(32)}` }, + }; + + expect( + new bindings.Contract(text, JSON.stringify(other), undefined, undefined).covenantAddress( + "liquid-testnet", + ), + ).not.toBe( + new bindings.Contract( + text, + JSON.stringify(ARGUMENTS.lending), + undefined, + undefined, + ).covenantAddress("liquid-testnet"), + ); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/README.md b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/README.md new file mode 100644 index 0000000..6b159fe --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/README.md @@ -0,0 +1,16 @@ +# The simplicity-lending contracts + +The five SimplicityHL sources the `lending`, `lending_v2` and `lending_v3` manifests +reference, from `BlockstreamResearch/simplicity-lending` at +`d0d46ffaf82c333d5e0650609969557100dc00d9`, vendored byte for byte. + +They are here because the manifests do not ship them — in production they arrive with +the request — and without them nothing can check that this wallet derives the addresses +a deployed protocol actually uses rather than merely deriving *some* address +reproducibly. + +`lending.simf` is the case the fixed point exists for: four of its thirteen parameters +are other covenants' script hashes, two of them the finalised form of the same vaults. + +None of the five declares a `simc` directive, so the compiler-version refusal has +nothing to check them against and they proceed. diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth.simf b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth.simf new file mode 100644 index 0000000..6abb6f5 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth.simf @@ -0,0 +1,39 @@ +fn get_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { + let pair: (Asset1, Amount1) = match is_input_index { + true => unwrap(jet::input_amount(index)), + false => unwrap(jet::output_amount(index)), + }; + let (asset, amount): (Asset1, Amount1) = pair; + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + let amount: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_bits, amount) +} + +fn ensure_output_is_op_return(index: u32) { + match jet::output_null_datum(index, 0) { + Some(entry: Option<Either<(u2, u256), Either<u1, u4>>>) => (), + None => panic!(), + } +} + +fn ensure_asset_and_amount_eq(index: u32, is_input_index: bool, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_asset_and_amount(index, is_input_index); + assert!(jet::eq_256(asset_bits, expected_asset_bits)); + assert!(jet::eq_64(amount, expected_amount)); +} + +fn auth_with_burn_check(input_asset_index: u32, output_asset_index: u32) { + ensure_asset_and_amount_eq(input_asset_index, true, param::ASSET_ID, param::ASSET_AMOUNT); + ensure_asset_and_amount_eq(output_asset_index, false, param::ASSET_ID, param::ASSET_AMOUNT); + + match param::WITH_ASSET_BURN { + true => { + ensure_output_is_op_return(output_asset_index); + }, + false => {}, + } +} + +fn main() { + auth_with_burn_check(witness::INPUT_ASSET_INDEX, witness::OUTPUT_ASSET_INDEX); +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth_vault.simf b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth_vault.simf new file mode 100644 index 0000000..8191f34 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth_vault.simf @@ -0,0 +1,245 @@ +// Helper getters + +fn get_script_hash(index: u32, is_input_index: bool) -> u256 { + let script_hash: u256 = match is_input_index { + true => unwrap(jet::input_script_hash(index)), + false => unwrap(jet::output_script_hash(index)), + }; + + script_hash +} + +fn get_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { + let pair: (Asset1, Amount1) = match is_input_index { + true => unwrap(jet::input_amount(index)), + false => unwrap(jet::output_amount(index)), + }; + let (asset, amount): (Asset1, Amount1) = pair; + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + let amount: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_bits, amount) +} + +fn is_op_return(output_index: u32) -> bool { + match jet::output_null_datum(output_index, 0) { + Some(entry: Option<Either<(u2, u256), Either<u1, u4>>>) => true, + None => false, + } +} + +// Math helpers + +fn safe_add_64(first: u64, second: u64) -> u64 { + let (carry, result): (bool, u64) = jet::add_64(first, second); + + result +} + +// Check helpers + +fn check_asset_amounts_eq(asset_amount_1: u64, asset_amount_2: u64) { + assert!(jet::eq_64(asset_amount_1, asset_amount_2)); +} + +fn check_assets_eq(asset_bits_1: u256, asset_bits_2: u256) { + assert!(jet::eq_256(asset_bits_1, asset_bits_2)); +} + +fn check_script_hashes_eq(script_1: u256, script_2: u256) { + assert!(jet::eq_256(script_1, script_2)); +} + +fn check_flags_eq(flag_1: bool, flag_2: bool) { + assert!(jet::eq_1(<bool>::into(flag_1), <bool>::into(flag_2))); +} + +fn ensure_output_is_op_return(index: u32) { + check_flags_eq(is_op_return(index), true); +} + +fn ensure_output_is_not_an_op_return(index: u32) { + check_flags_eq(is_op_return(index), false); +} + +fn ensure_asset_and_amount_ge(index: u32, is_input_index: bool, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_asset_and_amount(index, is_input_index); + assert!(jet::eq_256(asset_bits, expected_asset_bits)); + assert!(jet::le_64(expected_amount, amount)); +} + +fn ensure_active_status(expected_status: bool) { + assert!(jet::eq_1(<bool>::into(expected_status), <bool>::into(param::IS_ACTIVE))); +} + +fn ensure_output_script_hash(index: u32, expected_script_hash: u256) { + check_script_hashes_eq(get_script_hash(index, false), expected_script_hash); +} + +fn ensure_vault_asset(index: u32, is_input_index: bool) -> u64 { + let (vault_asset_id, vault_amount): (u256, u64) = get_asset_and_amount(index, is_input_index); + + check_assets_eq(vault_asset_id, param::VAULT_ASSET_ID); + + vault_amount +} + +fn ensure_vault_amount_after_supplying(vault_output_index: u32, amount_to_supply: u64) { + let vault_amount: u64 = ensure_vault_asset(jet::current_index(), true); + let output_vault_amount: u64 = ensure_vault_asset(vault_output_index, false); + + let expected_output_vault_amount: u64 = safe_add_64(vault_amount, amount_to_supply); + + check_asset_amounts_eq(output_vault_amount, expected_output_vault_amount); +} + +// Auth logic + +fn auth_with_burn_check( + input_asset_index: u32, + output_asset_index: u32, + expected_asset_id: u256, + expected_minimal_asset_amount: u64, + with_asset_burn: bool +) { + ensure_asset_and_amount_ge(input_asset_index, true, expected_asset_id, expected_minimal_asset_amount); + ensure_asset_and_amount_ge(output_asset_index, false, expected_asset_id, expected_minimal_asset_amount); + + match with_asset_burn { + true => { + ensure_output_is_op_return(output_asset_index); + }, + false => { + ensure_output_is_not_an_op_return(output_asset_index) + }, + } +} + +// Main paths logic + +fn withdraw_all(input_keeper_index: u32, output_keeper_index: u32) { + ensure_active_status(false); + + auth_with_burn_check( + input_keeper_index, + output_keeper_index, + param::KEEPER_AUTH_ASSET_ID, + param::KEEPER_AUTH_ASSET_AMOUNT, + param::WITH_KEEPER_ASSET_BURN + ); + + let vault_amount: u64 = ensure_vault_asset(jet::current_index(), true); +} + +fn withdraw_part(input_keeper_index: u32, output_keeper_index: u32, vault_output_index: u32, amount_to_withdraw: u64) { + ensure_active_status(true); + + auth_with_burn_check( + input_keeper_index, + output_keeper_index, + param::KEEPER_AUTH_ASSET_ID, + param::KEEPER_AUTH_ASSET_AMOUNT, + false + ); + + ensure_output_script_hash(vault_output_index, jet::current_script_hash()); + + let vault_amount: u64 = ensure_vault_asset(jet::current_index(), true); + let output_vault_amount: u64 = ensure_vault_asset(vault_output_index, false); + + assert!(jet::lt_64(amount_to_withdraw, vault_amount)); + + let (carry, vault_change): (bool, u64) = jet::subtract_64(vault_amount, amount_to_withdraw); + + check_asset_amounts_eq(output_vault_amount, vault_change); +} + +fn supply(input_supplier_index: u32, output_supplier_index: u32, vault_output_index: u32, amount_to_supply: u64) { + ensure_active_status(true); + + auth_with_burn_check( + input_supplier_index, + output_supplier_index, + param::SUPPLIER_AUTH_ASSET_ID, + 1, // Allow any amount of the supplier auth asset + false + ); + + ensure_output_script_hash(vault_output_index, jet::current_script_hash()); + + ensure_vault_amount_after_supplying(vault_output_index, amount_to_supply); +} + +// TODO: Add goal logic to finalization flow +fn final_supply( + input_supplier_index: u32, + output_supplier_index: u32, + finalized_vault_output_index: u32, + amount_to_supply: u64 +) { + ensure_active_status(true); + + auth_with_burn_check( + input_supplier_index, + output_supplier_index, + param::SUPPLIER_AUTH_ASSET_ID, + 1, // Allow any amount of the supplier auth asset + param::WITH_SUPPLIER_ASSET_BURN + ); + + ensure_output_script_hash(finalized_vault_output_index, param::FINALIZED_VAULT_COV_HASH); + + ensure_vault_amount_after_supplying(finalized_vault_output_index, amount_to_supply); +} + +fn main() { + match witness::PATH { + Left(withdraw_params: Either<(u32, u32), (u32, u32, u32, u64)>) => { + match withdraw_params { + Left(withdraw_all_params: (u32, u32)) => { + let (input_keeper_index, output_keeper_index): (u32, u32) = withdraw_all_params; + + withdraw_all(input_keeper_index, output_keeper_index); + }, + Right(withdraw_part_params: (u32, u32, u32, u64)) => { + let ( + input_keeper_index, + output_keeper_index, + vault_output_index, + amount_to_withdraw, + ): (u32, u32, u32, u64) = withdraw_part_params; + + withdraw_part(input_keeper_index, output_keeper_index, vault_output_index, amount_to_withdraw); + }, + } + }, + Right(supply_params: Either<(u32, u32, u32, u64), (u32, u32, u32, u64)>) => { + match supply_params { + Left(supply_params: (u32, u32, u32, u64)) => { + let ( + input_supplier_index, + output_supplier_index, + vault_output_index, + amount_to_supply, + ): (u32, u32, u32, u64) = supply_params; + + supply(input_supplier_index, output_supplier_index, vault_output_index, amount_to_supply); + }, + Right(final_supply_params: (u32, u32, u32, u64)) => { + let ( + input_supplier_index, + output_supplier_index, + finalized_vault_output_index, + amount_to_supply, + ): (u32, u32, u32, u64) = final_supply_params; + + final_supply( + input_supplier_index, + output_supplier_index, + finalized_vault_output_index, + amount_to_supply + ); + } + } + }, + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/issuance_factory.simf b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/issuance_factory.simf new file mode 100644 index 0000000..8a2fc92 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/issuance_factory.simf @@ -0,0 +1,242 @@ +// Helper getters + +fn get_script_hash(index: u32, is_input_index: bool) -> u256 { + let script_hash: u256 = match is_input_index { + true => unwrap(jet::input_script_hash(index)), + false => unwrap(jet::output_script_hash(index)), + }; + + script_hash +} + +fn get_explicit_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { + let pair: (Asset1, Amount1) = match is_input_index { + true => unwrap(jet::input_amount(index)), + false => unwrap(jet::output_amount(index)), + }; + let (asset, amount): (Asset1, Amount1) = pair; + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + let amount: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_bits, amount) +} + +fn get_current_explicit_asset() -> u256 { + let asset: Asset1 = jet::current_asset(); + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + + asset_bits +} + +fn is_op_return(output_index: u32) -> bool { + match jet::output_null_datum(output_index, 0) { + Some(entry: Option<Either<(u2, u256), Either<u1, u4>>>) => true, + None => false, + } +} + +// Check helpers + +fn check_asset_amounts_eq(asset_amount_1: u64, asset_amount_2: u64) { + assert!(jet::eq_64(asset_amount_1, asset_amount_2)); +} + +fn check_assets_eq(asset_bits_1: u256, asset_bits_2: u256) { + assert!(jet::eq_256(asset_bits_1, asset_bits_2)); +} + +fn check_script_hashes_eq(script_1: u256, script_2: u256) { + assert!(jet::eq_256(script_1, script_2)); +} + +fn check_flags_eq(flag_1: bool, flag_2: bool) { + assert!(jet::eq_1(<bool>::into(flag_1), <bool>::into(flag_2))); +} + +// Ensure functions + +fn ensure_output_is_op_return(index: u32) { + check_flags_eq(is_op_return(index), true); +} + +fn ensure_io_script_hashes_eq(input_index: u32, output_index: u32) { + let input_script_hash: u256 = get_script_hash(input_index, true); + let output_script_hash: u256 = get_script_hash(output_index, false); + + check_script_hashes_eq(input_script_hash, output_script_hash); +} + +fn ensure_non_zero_amount(amount: u64) { + assert!(jet::some_64(amount)); +} + +fn ensure_output_asset_and_amount(output_index: u32, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_explicit_asset_and_amount(output_index, false); + + check_assets_eq(asset_bits, expected_asset_bits); + check_asset_amounts_eq(amount, expected_amount); +} + +fn ensure_io_asset_eq(input_index: u32, output_index: u32, expected_asset_bits: u256) -> (u64, u64) { + let (input_asset_bits, input_amount): (u256, u64) = get_explicit_asset_and_amount(input_index, true); + let (output_asset_bits, output_amount): (u256, u64) = get_explicit_asset_and_amount(output_index, false); + + check_assets_eq(input_asset_bits, expected_asset_bits); + check_assets_eq(input_asset_bits, output_asset_bits); + + (input_amount, output_amount) +} + +fn ensure_io_asset_and_amount_eq( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + expected_amount: u64 +) { + let (input_amount, output_amount): (u64, u64) = ensure_io_asset_eq(input_index, output_index, expected_asset_bits); + + check_asset_amounts_eq(input_amount, expected_amount); + check_asset_amounts_eq(input_amount, output_amount); +} + +fn ensure_input_asset_and_amount_burn( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + expected_asset_amount: u64 +) { + ensure_io_asset_and_amount_eq(input_index, output_index, expected_asset_bits, expected_asset_amount); + + ensure_output_is_op_return(output_index); +} + +fn ensure_zero_bit(bit: bool) { assert!(jet::eq_1(<bool>::into(bit), 0)); } + +// Math helpers + +fn safe_add_32(first: u32, second: u32) -> u32 { + let (carry, result): (bool, u32) = jet::add_32(first, second); + + check_flags_eq(carry, false); + + result +} + +// Main paths helpers + +fn get_reissuance_flag(index: u8) -> bool { + let shifted: u64 = jet::right_shift_64(index, param::REISSUANCE_FLAGS); + let bit_val: u1 = jet::rightmost_64_1(shifted); + + <u1>::into(bit_val) +} + +fn verify_issuance(input_index: u32, output_index: u32, reissuance_flag: bool) { + let contract_hash: u256 = unwrap(unwrap(jet::new_issuance_contract(input_index))); + let outpoint: Outpoint = unwrap(jet::input_prev_outpoint(input_index)); + let issuance_entropy: u256 = jet::calculate_issuance_entropy(outpoint, contract_hash); + + let explicit_reissuance_tokens_amount: u64 = unwrap_right::<(u1, u256)>(unwrap(unwrap( + jet::issuance_token_amount(input_index) + ))); + + match reissuance_flag { + // TODO: Add confidential reissuance tokens verification + true => { + ensure_non_zero_amount(explicit_reissuance_tokens_amount); + }, + false => { + check_asset_amounts_eq(explicit_reissuance_tokens_amount, 0); + }, + }; + + let issuance_amount: Amount1 = unwrap(unwrap(jet::issuance_asset_amount(input_index))); + let issuance_asset: ExplicitAsset = unwrap(unwrap(jet::issuance_asset(input_index))); + + let issuance_amount: u64 = unwrap_right::<(u1, u256)>(issuance_amount); + + ensure_output_asset_and_amount(output_index, issuance_asset, issuance_amount); +} + +fn verify_issuance_step(acc: (), start_output_index: u32, i: u8) -> Either<(), ()> { + match jet::le_8(param::ISSUING_UTXOS_COUNT, i) { + true => Left(()), + false => { + let step_index: u32 = <(u16, u16)>::into((0, <(u8, u8)>::into((0, i)))); + + let input_index: u32 = safe_add_32(jet::current_index(), step_index); + let output_index: u32 = safe_add_32(start_output_index, step_index); + + verify_issuance(input_index, output_index, get_reissuance_flag(i)); + + Right(()) + } + } +} + +// Assets issuance flow + +fn get_asset_issuance_issuance_factory_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), safe_add_32(start_output_index, 1)) +} + +fn get_asset_issuance_start_issued_output_index(start_output_index: u32) -> u32 { + safe_add_32(start_output_index, 2) +} + +fn issue_new_assets(output_index: u32) { + let (auth_nft_input_index, auth_nft_output_index): (u32, u32) = (0, output_index); + let ( + issuance_factory_input_index, + issuance_factory_output_index + ): (u32, u32) = get_asset_issuance_issuance_factory_indexes(auth_nft_input_index, auth_nft_output_index); + + assert!(jet::eq_32(issuance_factory_input_index, jet::current_index())); + + let current_asset_bits: u256 = get_current_explicit_asset(); + + ensure_io_asset_and_amount_eq(auth_nft_input_index, auth_nft_output_index, current_asset_bits, 1); + ensure_io_asset_and_amount_eq(issuance_factory_input_index, issuance_factory_output_index, current_asset_bits, 1); + + ensure_io_script_hashes_eq(issuance_factory_input_index, issuance_factory_output_index); + + let issued_outputs_start_index: u32 = get_asset_issuance_start_issued_output_index(auth_nft_output_index); + + unwrap_left::<()>(for_while::<verify_issuance_step>((), issued_outputs_start_index)); +} + +// Factory removing flow + +fn get_removing_auth_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), safe_add_32(start_output_index, 1)) +} + +fn remove_factory(output_index: u32) { + let (issuance_factory_input_index, issuance_factory_output_index): (u32, u32) = (0, output_index); + let ( + auth_nft_input_index, + auth_nft_output_index + ): (u32, u32) = get_removing_auth_nft_indexes(issuance_factory_input_index, issuance_factory_output_index); + + assert!(jet::eq_32(issuance_factory_input_index, jet::current_index())); + + let current_asset_bits: u256 = get_current_explicit_asset(); + + ensure_input_asset_and_amount_burn( + issuance_factory_input_index, + issuance_factory_output_index, + current_asset_bits, + 1 + ); + ensure_input_asset_and_amount_burn(auth_nft_input_index, auth_nft_output_index, current_asset_bits, 1); +} + +fn main() { + match witness::PATH { + Left(output_index: u32) => { + issue_new_assets(output_index); + }, + Right(output_index: u32) => { + remove_factory(output_index); + } + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/lending.simf b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/lending.simf new file mode 100644 index 0000000..ea64937 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/lending.simf @@ -0,0 +1,599 @@ +// Helper getters + +fn get_script_hash(index: u32, is_input_index: bool) -> u256 { + let script_hash: u256 = match is_input_index { + true => unwrap(jet::input_script_hash(index)), + false => unwrap(jet::output_script_hash(index)), + }; + + script_hash +} + +fn get_explicit_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { + let pair: (Asset1, Amount1) = match is_input_index { + true => unwrap(jet::input_amount(index)), + false => unwrap(jet::output_amount(index)), + }; + let (asset, amount): (Asset1, Amount1) = pair; + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + let amount: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_bits, amount) +} + +fn is_op_return(output_index: u32) -> bool { + match jet::output_null_datum(output_index, 0) { + Some(entry: Option<Either<(u2, u256), Either<u1, u4>>>) => true, + None => false, + } +} + +// Check helpers + +fn check_asset_amounts_eq(asset_amount_1: u64, asset_amount_2: u64) { + assert!(jet::eq_64(asset_amount_1, asset_amount_2)); +} + +fn check_assets_eq(asset_bits_1: u256, asset_bits_2: u256) { + assert!(jet::eq_256(asset_bits_1, asset_bits_2)); +} + +fn check_script_hashes_eq(script_1: u256, script_2: u256) { + assert!(jet::eq_256(script_1, script_2)); +} + +fn check_flags_eq(flag_1: bool, flag_2: bool) { + assert!(jet::eq_1(<bool>::into(flag_1), <bool>::into(flag_2))); +} + +// Math helpers + +fn safe_add_64(first: u64, second: u64) -> u64 { + let (carry, result): (bool, u64) = jet::add_64(first, second); + + check_flags_eq(carry, false); + + result +} + +fn safe_add_32(first: u32, second: u32) -> u32 { + let (carry, result): (bool, u32) = jet::add_32(first, second); + + check_flags_eq(carry, false); + + result +} + +fn safe_sub_64(first: u64, second: u64) -> u64 { + let (carry, result): (bool, u64) = jet::subtract_64(first, second); + + check_flags_eq(carry, false); + + result +} + +fn min_64(first: u64, second: u64) -> u64 { + match jet::lt_64(first, second) { + true => first, + false => second, + } +} + +// Ensure functions + +fn ensure_output_is_op_return(index: u32) { + check_flags_eq(is_op_return(index), true); +} + +fn ensure_input_script_hash(input_index: u32, expected_script_hash: u256) { + let script_hash: u256 = get_script_hash(input_index, true); + + check_script_hashes_eq(script_hash, expected_script_hash); +} + +fn ensure_output_script_hash(output_index: u32, expected_script_hash: u256) { + let script_hash: u256 = get_script_hash(output_index, false); + + check_script_hashes_eq(script_hash, expected_script_hash); +} + +fn ensure_script_hash_transition( + input_index: u32, + output_index: u32, + expected_input_script_hash: u256, + expected_output_script_hash: u256 +) { + ensure_input_script_hash(input_index, expected_input_script_hash); + ensure_output_script_hash(output_index, expected_output_script_hash); +} + +fn ensure_input_asset_and_amount(input_index: u32, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_explicit_asset_and_amount(input_index, true); + + check_assets_eq(asset_bits, expected_asset_bits); + check_asset_amounts_eq(amount, expected_amount); +} + +fn ensure_output_asset_and_amount(output_index: u32, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_explicit_asset_and_amount(output_index, false); + + check_assets_eq(asset_bits, expected_asset_bits); + check_asset_amounts_eq(amount, expected_amount); +} + +fn ensure_io_asset_eq(input_index: u32, output_index: u32, expected_asset_bits: u256) -> (u64, u64) { + let (input_asset_bits, input_amount): (u256, u64) = get_explicit_asset_and_amount(input_index, true); + let (output_asset_bits, output_amount): (u256, u64) = get_explicit_asset_and_amount(output_index, false); + + check_assets_eq(input_asset_bits, expected_asset_bits); + check_assets_eq(input_asset_bits, output_asset_bits); + + (input_amount, output_amount) +} + +fn ensure_io_asset_and_amount_eq( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + expected_amount: u64 +) { + let (input_amount, output_amount): (u64, u64) = ensure_io_asset_eq(input_index, output_index, expected_asset_bits); + + check_asset_amounts_eq(input_amount, expected_amount); + check_asset_amounts_eq(input_amount, output_amount); +} + +fn ensure_input_asset_and_amount_burn( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + expected_asset_amount: u64 +) { + ensure_io_asset_and_amount_eq(input_index, output_index, expected_asset_bits, expected_asset_amount); + + ensure_output_is_op_return(output_index); +} + +fn ensure_asset_transition_with_additional_amount( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + additional_amount: u64 +) { + let (input_amount, output_amount): (u64, u64) = ensure_io_asset_eq(input_index, output_index, expected_asset_bits); + + let new_asset_amount: u64 = safe_add_64(input_amount, additional_amount); + + check_asset_amounts_eq(output_amount, new_asset_amount); +} + +// Math helpers + +fn mul_div(x: u64, y: u64, denominator: u64) -> u64 { + let product: u128 = jet::multiply_64(x, y); + + // TODO: Handle case when hi > 0 + let (hi, lo): (u64, u64) = <u128>::into(product); + check_asset_amounts_eq(hi, 0); + + let result: u64 = jet::divide_64(lo, denominator); + + result +} + +// Basis points math + +fn get_max_basis_points() -> u64 { + 10_000 +} + +fn apply_basis_points(amount: u64, bps: u64) -> u64 { + mul_div(amount, bps, get_max_basis_points()) +} + +// Storage helpers + +fn get_is_active_slot_leaf(is_active: bool) -> u256 { + let is_active_num: u64 = jet::left_pad_low_1_64(<bool>::into(is_active)); + let is_active_num: u256 = <(u128, u128)>::into((0, <(u64, u64)>::into((0, is_active_num)))); + + let state_ctx1: Ctx8 = jet::tapdata_init(); + let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, is_active_num); + + jet::sha_256_ctx_8_finalize(state_ctx2) +} + +fn get_borrower_debt_slot_leaf(borrower_debt: u64) -> u256 { + let borrower_debt: u256 = <(u128, u128)>::into((0, <(u64, u64)>::into((0, borrower_debt)))); + + let state_ctx1: Ctx8 = jet::tapdata_init(); + let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, borrower_debt); + + jet::sha_256_ctx_8_finalize(state_ctx2) +} + +fn get_script_hash_for_storage(is_active: bool, borrower_debt: u64) -> u256 { + let is_active_slot_leaf: u256 = get_is_active_slot_leaf(is_active); + let borrower_debt_slot_leaf: u256 = get_borrower_debt_slot_leaf(borrower_debt); + + let first_tap_node: u256 = jet::build_tapbranch(jet::tapleaf_hash(), is_active_slot_leaf); + let tap_node: u256 = jet::build_tapbranch(first_tap_node, borrower_debt_slot_leaf); + + // Compute a TapTweak + let bip0341_key: u256 = 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0; + let tweaked_key: u256 = jet::build_taptweak(bip0341_key, tap_node); + + // Turn the TapTweak into a script hash + let hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init(); + let hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_2(hash_ctx1, 0x5120); // Segwit v1, length 32 + let hash_ctx3: Ctx8 = jet::sha_256_ctx_8_add_32(hash_ctx2, tweaked_key); + + jet::sha_256_ctx_8_finalize(hash_ctx3) +} + +// Main paths helpers + +fn get_protocol_fee_bps() -> u64 { + 1_000 // 10% +} + +fn get_protocol_fee_amount(fee_amount: u64) -> u64 { + apply_basis_points(fee_amount, get_protocol_fee_bps()) +} + +fn get_total_fee_amount() -> u64 { + apply_basis_points(param::PRINCIPAL_AMOUNT, param::PRINCIPAL_INTEREST_RATE) +} + +fn get_total_amount_to_repay() -> u64 { + safe_add_64(param::PRINCIPAL_AMOUNT, get_total_fee_amount()) +} + +fn get_collateral_for_principal(principal_amount: u64) -> u64 { + mul_div(principal_amount, param::COLLATERAL_AMOUNT, get_total_amount_to_repay()) +} + +fn split_repayment_by_fees(fee_left: u64, amount_to_repay: u64) -> (u64, u64) { + let fee_repaid: u64 = min_64(fee_left, amount_to_repay); + let protocol_fee_repaid: u64 = get_protocol_fee_amount(fee_repaid); + + (fee_repaid, protocol_fee_repaid) +} + +fn validate_collateral_input(input_index: u32, current_debt: u64) -> u64 { + let (asset_bits, current_collateral_amount): (u256, u64) = get_explicit_asset_and_amount(input_index, true); + + let already_paid_debt: u64 = safe_sub_64(get_total_amount_to_repay(), current_debt); + let already_unlocked_collateral: u64 = get_collateral_for_principal(already_paid_debt); + let total_collateral_amount: u64 = safe_add_64(current_collateral_amount, already_unlocked_collateral); + + check_assets_eq(asset_bits, param::COLLATERAL_ASSET_ID); + check_asset_amounts_eq(total_collateral_amount, param::COLLATERAL_AMOUNT); + + current_collateral_amount +} + +fn validate_vaults( + lender_vault_indexes: (u32, u32), + protocol_fee_vault_indexes: (u32, u32), + current_borrower_debt: u64, + amount_to_repay: u64 +) { + let total_fee_amount: u64 = get_total_fee_amount(); + + let already_repaid_amount: u64 = safe_sub_64(get_total_amount_to_repay(), current_borrower_debt); + + let ( + already_repaid_fee, + already_repaid_protocol_fee + ): (u64, u64) = split_repayment_by_fees(total_fee_amount, already_repaid_amount); + + let fee_left: u64 = safe_sub_64(total_fee_amount, already_repaid_fee); + + let (fee_repaid, protocol_fee_repaid): (u64, u64) = split_repayment_by_fees(fee_left, amount_to_repay); + + let total_repaid_protocol_fee: u64 = safe_add_64(already_repaid_protocol_fee, protocol_fee_repaid); + let additional_lender_vault_amount: u64 = safe_sub_64(amount_to_repay, protocol_fee_repaid); + + let total_protocol_fee: u64 = get_protocol_fee_amount(total_fee_amount); + + let lender_vault_output_hash: u256 = match jet::eq_64(current_borrower_debt, amount_to_repay) { + true => param::FINALIZED_LENDER_VAULT_COV_HASH, + false => param::LENDER_VAULT_COV_HASH, + }; + let protocol_fee_vault_output_hash: u256 = match jet::eq_64(total_repaid_protocol_fee, total_protocol_fee) { + true => param::FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH, + false => param::PROTOCOL_FEE_VAULT_COV_HASH, + }; + + let (lender_vault_input_index, lender_vault_output_index): (u32, u32) = lender_vault_indexes; + let (protocol_fee_vault_input_index, protocol_fee_vault_output_index): (u32, u32) = protocol_fee_vault_indexes; + + match jet::some_64(already_repaid_amount) { + true => { + ensure_asset_transition_with_additional_amount( + lender_vault_input_index, + lender_vault_output_index, + param::PRINCIPAL_ASSET_ID, + additional_lender_vault_amount + ); + ensure_script_hash_transition( + lender_vault_input_index, + lender_vault_output_index, + param::LENDER_VAULT_COV_HASH, + lender_vault_output_hash + ); + + match jet::lt_64(already_repaid_protocol_fee, total_protocol_fee) { + true => { + ensure_asset_transition_with_additional_amount( + protocol_fee_vault_input_index, + protocol_fee_vault_output_index, + param::PRINCIPAL_ASSET_ID, + protocol_fee_repaid + ); + ensure_script_hash_transition( + protocol_fee_vault_input_index, + protocol_fee_vault_output_index, + param::PROTOCOL_FEE_VAULT_COV_HASH, + protocol_fee_vault_output_hash + ); + }, + false => {}, + }; + }, + false => { + ensure_output_asset_and_amount( + lender_vault_output_index, + param::PRINCIPAL_ASSET_ID, + additional_lender_vault_amount + ); + ensure_output_script_hash(lender_vault_output_index, lender_vault_output_hash); + + ensure_output_asset_and_amount( + protocol_fee_vault_output_index, + param::PRINCIPAL_ASSET_ID, + protocol_fee_repaid + ); + ensure_output_script_hash(protocol_fee_vault_output_index, protocol_fee_vault_output_hash); + }, + }; +} + +// Partial repayment flow + +fn get_partial_repayment_offer_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), safe_add_32(start_output_index, 1)) +} + +fn get_partial_repayment_lender_vault_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 2), safe_add_32(start_output_index, 2)) +} + +fn get_partial_repayment_protocol_fee_vault_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 3), safe_add_32(start_output_index, 3)) +} + +fn partial_repay_offer(current_debt: u64, amount_to_repay: u64) { + let (borrower_nft_input_index, borrower_nft_output_index): (u32, u32) = (0, 0); + let ( + offer_input_index, + offer_output_index + ): (u32, u32) = get_partial_repayment_offer_indexes(borrower_nft_input_index, borrower_nft_output_index); + + assert!(jet::eq_32(jet::current_index(), offer_input_index)); + + let current_script_hash: u256 = get_script_hash_for_storage(true, current_debt); + ensure_input_script_hash(offer_input_index, current_script_hash); + + assert!(jet::some_64(amount_to_repay)); + assert!(jet::lt_64(amount_to_repay, current_debt)); + + let current_collateral_amount: u64 = validate_collateral_input(offer_input_index, current_debt); + let collateral_to_unlock: u64 = get_collateral_for_principal(amount_to_repay); + let new_collateral_amount: u64 = safe_sub_64(current_collateral_amount, collateral_to_unlock); + + ensure_output_asset_and_amount(offer_output_index, param::COLLATERAL_ASSET_ID, new_collateral_amount); + + let new_debt: u64 = safe_sub_64(current_debt, amount_to_repay); + let new_offer_script_hash: u256 = get_script_hash_for_storage(true, new_debt); + + ensure_output_script_hash(offer_output_index, new_offer_script_hash); + + ensure_io_asset_and_amount_eq( + borrower_nft_input_index, + borrower_nft_output_index, + param::BORROWER_NFT_ASSET_ID, + 1 + ); + + validate_vaults( + get_partial_repayment_lender_vault_indexes(borrower_nft_input_index, borrower_nft_output_index), + get_partial_repayment_protocol_fee_vault_indexes(borrower_nft_input_index, borrower_nft_output_index), + current_debt, + amount_to_repay + ); +} + +// Full repayment flow + +fn get_full_repayment_offer_input_index(start_input_index: u32) -> u32 { + safe_add_32(start_input_index, 1) +} + +fn get_full_repayment_lender_vault_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 2), safe_add_32(start_output_index, 1)) +} + +fn get_full_repayment_protocol_fee_vault_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 3), safe_add_32(start_output_index, 2)) +} + +fn full_repay_offer(current_debt: u64) { + let (borrower_nft_input_index, borrower_nft_output_index): (u32, u32) = (0, 0); + let offer_input_index: u32 = get_full_repayment_offer_input_index(borrower_nft_input_index); + + assert!(jet::eq_32(jet::current_index(), offer_input_index)); + + let current_script_hash: u256 = get_script_hash_for_storage(true, current_debt); + ensure_input_script_hash(offer_input_index, current_script_hash); + + let _: u64 = validate_collateral_input(offer_input_index, current_debt); + + ensure_input_asset_and_amount_burn( + borrower_nft_input_index, + borrower_nft_output_index, + param::BORROWER_NFT_ASSET_ID, + 1 + ); + + validate_vaults( + get_full_repayment_lender_vault_indexes(borrower_nft_input_index, borrower_nft_output_index), + get_full_repayment_protocol_fee_vault_indexes(borrower_nft_input_index, borrower_nft_output_index), + current_debt, + current_debt + ); +} + +// Liquidation flow + +fn get_liquidation_lender_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), start_output_index) +} + +fn liquidate_offer(current_debt: u64) { + let (lending_input_index, lending_output_index): (u32, u32) = (jet::current_index(), 0); + let ( + lender_nft_input_index, + lender_nft_output_index + ): (u32, u32) = get_liquidation_lender_nft_indexes(lending_input_index, lending_output_index); + + assert!(jet::eq_32(lending_input_index, 0)); + + let current_script_hash: u256 = get_script_hash_for_storage(true, current_debt); + ensure_input_script_hash(lending_input_index, current_script_hash); + + jet::check_lock_height(param::LOAN_EXPIRATION_TIME); + + let _: u64 = validate_collateral_input(lending_input_index, current_debt); + + ensure_input_asset_and_amount_burn(lender_nft_input_index, lender_nft_output_index, param::LENDER_NFT_ASSET_ID, 1); +} + +// Offer acceptance flow + +fn get_acceptance_principal_output_index(start_output_index: u32) -> u32 { + safe_add_32(start_output_index, 1) +} + +fn get_acceptance_lender_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), safe_add_32(start_output_index, 2)) +} + +fn accept_offer() { + let (lending_input_index, lending_output_index): (u32, u32) = (jet::current_index(), 0); + let ( + lender_nft_input_index, + lender_nft_output_index + ): (u32, u32) = get_acceptance_lender_nft_indexes(lending_input_index, lending_output_index); + let principal_output_index: u32 = get_acceptance_principal_output_index(lending_output_index); + + assert!(jet::eq_32(lending_input_index, 0)); + + let total_amount_to_repay: u64 = get_total_amount_to_repay(); + let current_script_hash: u256 = get_script_hash_for_storage(false, total_amount_to_repay); + let active_offer_script_hash: u256 = get_script_hash_for_storage(true, total_amount_to_repay); + + ensure_io_asset_and_amount_eq( + lending_input_index, + lending_output_index, + param::COLLATERAL_ASSET_ID, + param::COLLATERAL_AMOUNT + ); + ensure_script_hash_transition( + lending_input_index, + lending_output_index, + current_script_hash, + active_offer_script_hash + ); + + ensure_io_asset_and_amount_eq(lender_nft_input_index, lender_nft_output_index, param::LENDER_NFT_ASSET_ID, 1); + + ensure_output_asset_and_amount(principal_output_index, param::PRINCIPAL_ASSET_ID, param::PRINCIPAL_AMOUNT); + ensure_output_script_hash(principal_output_index, param::PRINCIPAL_OUTPUT_SCRIPT_HASH); +} + +// Offer cancellation flow + +fn get_cancellation_lender_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), start_output_index) +} + +fn get_cancellation_borrower_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 2), safe_add_32(start_output_index, 1)) +} + +fn cancel_offer() { + let lending_input_index: u32 = jet::current_index(); + let ( + lender_nft_input_index, + lender_nft_output_index + ): (u32, u32) = get_cancellation_lender_nft_indexes(lending_input_index, 0); + let ( + borrower_nft_input_index, + borrower_nft_output_index + ): (u32, u32) = get_cancellation_borrower_nft_indexes(lending_input_index, 0); + + assert!(jet::eq_32(lending_input_index, 0)); + + let current_script_hash: u256 = get_script_hash_for_storage(false, get_total_amount_to_repay()); + ensure_input_script_hash(lending_input_index, current_script_hash); + + ensure_input_asset_and_amount(lending_input_index, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); + + ensure_input_asset_and_amount_burn( + borrower_nft_input_index, + borrower_nft_output_index, + param::BORROWER_NFT_ASSET_ID, + 1 + ); + ensure_input_asset_and_amount_burn( + lender_nft_input_index, + lender_nft_output_index, + param::LENDER_NFT_ASSET_ID, + 1 + ); +} + +fn main() { + match witness::PATH { + Left(pending_offer_params: Either<(), ()>) => { + match pending_offer_params { + Left(params: ()) => { + accept_offer(); + }, + Right(params: ()) => { + cancel_offer(); + }, + } + }, + Right(active_offer_params: Either<Either<(u64, u64), u64>, u64>) => { + match active_offer_params { + Left(repayment_params: Either<(u64, u64), u64>) => { + match repayment_params { + Left(partial_repayment_params: (u64, u64)) => { + let (current_debt, amount_to_repay): (u64, u64) = partial_repayment_params; + + partial_repay_offer(current_debt, amount_to_repay); + }, + Right(current_debt: u64) => { + full_repay_offer(current_debt); + }, + } + }, + Right(current_debt: u64) => { + liquidate_offer(current_debt); + } + } + }, + } +} \ No newline at end of file diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/script_auth.simf b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/script_auth.simf new file mode 100644 index 0000000..35566f9 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/script_auth.simf @@ -0,0 +1,13 @@ +fn ensure_input_script_hash(input_script_index: u32, expected_script_hash: u256) { + let actual_script_hash: u256 = unwrap(jet::input_script_hash(input_script_index)); + + assert!(jet::eq_256(actual_script_hash, expected_script_hash)); +} + +fn script_auth_check(input_script_index: u32) { + ensure_input_script_hash(input_script_index, param::SCRIPT_HASH); +} + +fn main() { + script_auth_check(witness::INPUT_SCRIPT_INDEX); +} \ No newline at end of file From a5adf77b66de0b0a8629c4ab82cb825843925ca1 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:05:17 +0300 Subject: [PATCH 051/124] feat(liquid): encode the boolean parameters the deployed contracts take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asset_auth takes a burn flag and asset_auth_vault takes three, so a manifest wiring a value into one was refused for want of an encoding. The manifests alone would not have shown this — only their contracts do, which is what vendoring them was for. Written as the compiler reads it rather than as bytes, and anything that is neither of the two words it knows goes through unchanged so the compiler refuses it by name. Turning an unrecognised value into false here would build a different covenant at a different address. --- .../domain/manifest/compileParams.test.ts | 44 +++++++++++++++++++ .../liquid/domain/manifest/compileParams.ts | 22 +++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts index 2120a21..a8ec8d2 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts @@ -95,3 +95,47 @@ describe("resolveCompileParams", () => { expect(result).toMatchObject({ ok: false }); }); }); + +// The deployed lending contracts take boolean parameters — asset_auth a burn flag, +// asset_auth_vault three of them — so a manifest wiring one has to be encodable. +describe("boolean compile parameters", () => { + test("a true is written as the compiler reads it, not as bytes", () => { + const result = resolveCompileParams( + { WITH_ASSET_BURN: "params.burn" }, + { burn: "bool" }, + scope({ burn: "true" }), + ); + + expect(result).toEqual({ + arguments: { WITH_ASSET_BURN: { type: "bool", value: "true" } }, + ok: true, + }); + }); + + test("a false too", () => { + const result = resolveCompileParams( + { WITH_ASSET_BURN: "params.burn" }, + { burn: "bool" }, + scope({ burn: "false" }), + ); + + expect(result).toMatchObject({ arguments: { WITH_ASSET_BURN: { value: "false" } } }); + }); + + test("a one and a zero are read as the booleans they spell", () => { + expect( + resolveCompileParams({ B: "params.burn" }, { burn: "bool" }, scope({ burn: "1" })), + ).toMatchObject({ arguments: { B: { value: "true" } } }); + expect( + resolveCompileParams({ B: "params.burn" }, { burn: "bool" }, scope({ burn: "0" })), + ).toMatchObject({ arguments: { B: { value: "false" } } }); + }); + + // Anything else goes through unchanged so the compiler names the type, rather than being + // turned into `false` here — which would be a different covenant at a different address. + test("anything else is left for the compiler to refuse by name", () => { + expect( + resolveCompileParams({ B: "params.burn" }, { burn: "bool" }, scope({ burn: "maybe" })), + ).toMatchObject({ arguments: { B: { value: "maybe" } } }); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts index c38b059..ed19470 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts @@ -22,6 +22,10 @@ export type ResolveCompileParamsResult = * `liquid.asset_id` and `address` — arrive with the slices that need them. */ const PARAM_TYPES: Record<string, string> = { + // The deployed lending contracts take these: `asset_auth` a burn flag, `asset_auth_vault` + // three of them. A manifest wiring a value into one is refused without an encoding for it, + // so the corpus's own contracts are what say this is needed. + bool: "bool", // A covenant script hash is thirty-two bytes. `u256` rather than one of the compiler's // aliases because they are the same type: `Pubkey`, `Message`, `Scalar`, `Fe`, // `ExplicitAsset` and `ExplicitNonce` all resolve to `U256` in simplicityhl 0.6.0 @@ -80,7 +84,12 @@ export function resolveCompileParams( }; } - resolved[name] = { type: compilerType, value: withHexPrefix(found.value) }; + // A boolean is written as itself rather than as bytes: the compiler reads `true` and + // `false`, and a hex-prefixed one is not an expression of that type. + resolved[name] = + compilerType === "bool" + ? { type: compilerType, value: booleanLiteral(found.value) } + : { type: compilerType, value: withHexPrefix(found.value) }; } return { arguments: resolved, ok: true }; @@ -102,6 +111,17 @@ function declaredTypeOf( return name === undefined ? undefined : declaredTypes[name]; } +/** + * A boolean as the compiler writes it. + * + * Anything other than the two words it reads is passed through unchanged, so a manifest + * carrying something else is refused by the compiler naming the type rather than being + * quietly turned into `false` — which is a different covenant. + */ +function booleanLiteral(value: string): string { + return value === "1" ? "true" : value === "0" ? "false" : value; +} + function withHexPrefix(value: string): string { return value.startsWith("0x") ? value : `0x${value}`; } From d889c13052358b7031d26a3255fa76ca7112e2d1 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:08:35 +0300 Subject: [PATCH 052/124] build: teach CI about the second wasm module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension does not build without smplx_wasm any more — the manifest runtime compiles Simplicity contracts and signs with them — and the workflow knew only about lwk. Mirrors the existing lwk action: cached by the pinned submodule commit, so the Rust build runs only when the submodule bumps. A C compiler with a WebAssembly backend is installed rather than exported, because the fork's build script already searches for one and fails with a clear message when there is none; putting one on PATH keeps that guard instead of bypassing it. This does not make CI pass yet. The fork's branch is unpublished, so a recursive checkout cannot resolve the gitlink — the cost recorded when publishing was moved to this slice, and it clears when the branch is pushed. --- .github/actions/build-smplx-wasm/action.yml | 67 +++++++++++++++++++++ .github/workflows/build-extension.yml | 7 ++- smplx | 2 +- 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 .github/actions/build-smplx-wasm/action.yml diff --git a/.github/actions/build-smplx-wasm/action.yml b/.github/actions/build-smplx-wasm/action.yml new file mode 100644 index 0000000..e406159 --- /dev/null +++ b/.github/actions/build-smplx-wasm/action.yml @@ -0,0 +1,67 @@ +name: Build smplx_wasm +description: >- + Build the smplx_wasm WASM package from the vendored `smplx` git submodule so the + `file:smplx/crates/wasm/pkg` dependency resolves. Like `lwk_wasm/pkg`, it is a wasm-pack + build artifact that is not committed, so it must be produced in CI. The output is cached + by the pinned submodule commit, so the Rust build only runs when the submodule bumps. + Requires the repo to be checked out with `submodules: recursive`. + + The fork's build script needs a C compiler with a WebAssembly backend — Apple's system + clang has none, and neither does a bare ubuntu runner without LLVM's clang on PATH, which + is why one is installed rather than assumed. Without it the build fails inside + `secp256k1-sys` and `simplicity-sys` with "unable to create target", which points at the + crates and misleads. + +runs: + using: composite + steps: + - name: Resolve pinned smplx commit + id: smplx + shell: bash + run: echo "sha=$(git rev-parse HEAD:smplx)" >> "$GITHUB_OUTPUT" + + - name: Restore built smplx pkg + id: pkg-cache + uses: actions/cache@v4 + with: + path: smplx/crates/wasm/pkg + key: smplx-wasm-${{ runner.os }}-${{ steps.smplx.outputs.sha }} + + - name: Install Rust toolchain (1.85.0 + wasm32) + if: steps.pkg-cache.outputs.cache-hit != 'true' + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.85.0" + targets: wasm32-unknown-unknown + + - name: Cache cargo registry + build + if: steps.pkg-cache.outputs.cache-hit != 'true' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + smplx/target + key: cargo-smplx-${{ runner.os }}-${{ steps.smplx.outputs.sha }} + restore-keys: | + cargo-smplx-${{ runner.os }}- + + - name: Install wasm-pack + if: steps.pkg-cache.outputs.cache-hit != 'true' + uses: jetli/wasm-pack-action@v0.4.0 + + # Installed rather than exported: the fork's build script already searches for a clang + # with a WebAssembly backend and fails with a clear message when there is none, so + # putting one on PATH keeps that guard rather than bypassing it. + - name: Install a C compiler with a WebAssembly backend + if: steps.pkg-cache.outputs.cache-hit != 'true' + shell: bash + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y clang llvm + + - name: Build smplx_wasm + if: steps.pkg-cache.outputs.cache-hit != 'true' + shell: bash + run: smplx/crates/wasm/build.sh diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml index 855d1d5..b130412 100644 --- a/.github/workflows/build-extension.yml +++ b/.github/workflows/build-extension.yml @@ -25,7 +25,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - name: Checkout (with the lwk submodule) + - name: Checkout (with the lwk and smplx submodules) uses: actions/checkout@v4 with: submodules: recursive @@ -38,6 +38,11 @@ jobs: with: profile: ${{ inputs.profile }} + # The manifest runtime compiles Simplicity contracts and signs with them, so the + # extension does not build without this package either. + - name: Build smplx_wasm + uses: ./.github/actions/build-smplx-wasm + - name: Install dependencies uses: ./.github/actions/install diff --git a/smplx b/smplx index 5b2dd7a..d925dba 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 5b2dd7a3e0121489a1ceda151f5251dd33cc1697 +Subproject commit d925dbab4f9cb3da501e33a1e3993dad23165da5 From fb9ac5e53309048fa9ea915495011b479a1197c0 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:10:12 +0300 Subject: [PATCH 053/124] chore: take the workflow environment upgrade that was sitting in the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not this change's work — a wfctl upgrade someone applied, plus the one-line repair that keeps the stop guard's state out of the directory wfctl parses as work bindings. Committed because an uncommitted upgrade blocks the clean-checkout gate that closing any change needs, and because leaving it in the tree is how it gets discovered by whoever next runs git status rather than by whoever applied it. The guard's own state file is ignored: it is per-session runtime bookkeeping. --- .../skills/align-project-knowledge/SKILL.md | 6 ++- .../skills/explore-project-knowledge/SKILL.md | 4 +- .../manage-project-work/assets/capture.md | 9 ++++ .claude/rules/evidence-first.md | 9 +++- .claude/rules/execution-continuity.md | 11 +++- .claude/rules/maintainer-review.md | 52 +++++++++++++++++++ .claude/rules/project-work-bundles.md | 5 ++ .../skills/align-project-knowledge/SKILL.md | 6 ++- .../skills/explore-project-knowledge/SKILL.md | 4 +- .../manage-project-work/assets/capture.md | 9 ++++ .gitignore | 5 ++ .workflow/rules/evidence-first.md | 9 +++- .workflow/rules/execution-continuity.md | 11 +++- .workflow/rules/maintainer-review.md | 52 +++++++++++++++++++ .workflow/rules/project-work-bundles.md | 5 ++ .workflow/runtime/guard-stop.mjs | 50 +++++++++++------- .workflow/state.json | 18 +++---- AGENTS.md | 27 ++++++---- skills-lock.json | 6 +-- 19 files changed, 247 insertions(+), 51 deletions(-) diff --git a/.agents/skills/align-project-knowledge/SKILL.md b/.agents/skills/align-project-knowledge/SKILL.md index 8832718..7d3cd0c 100644 --- a/.agents/skills/align-project-knowledge/SKILL.md +++ b/.agents/skills/align-project-knowledge/SKILL.md @@ -15,7 +15,11 @@ Do not design from code and memory alone. Establish the project's current intent invoke `setup-workflow-environment` to repair or reinstall the selected project/user skills, then ask only for the unavoidable agent-session restart. An on-disk file alone does not prove the running agent loaded it. -3. Run QMD from that knowledge root. Require `qmd status`; if QMD or the +3. Run QMD from that knowledge root. Require `qmd status`; if it reports + documents pending embedding, run `qmd embed` before relying on vector or + hybrid retrieval — indexing and embedding are separate, `qmd update` only + marks what needs vectors, and searching without them silently degrades to + lexical BM25 over exactly the material most recently written. If QMD or the project-local `.qmd/index.yml` is missing, stop and report the broken workflow environment. 4. Run `wfctl knowledge build --target <knowledge-root>`. Stop alignment if diff --git a/.agents/skills/explore-project-knowledge/SKILL.md b/.agents/skills/explore-project-knowledge/SKILL.md index 2ede90d..4e93f28 100644 --- a/.agents/skills/explore-project-knowledge/SKILL.md +++ b/.agents/skills/explore-project-knowledge/SKILL.md @@ -16,7 +16,9 @@ first broad discovery request in a session. 1. If the current repository has the `knowledge` profile, work from it. 2. If the current repository is a leaf, read `.workflow/config.json`, resolve its configured knowledge repository, and answer from that repository. -3. Require and invoke the native QMD skill. Check `qmd status`, then search only +3. Require and invoke the native QMD skill. Check `qmd status`, and when it + reports documents pending embedding run `qmd embed` first: without vectors + the search degrades to lexical BM25 over the newest material. Then search only the `knowledge` collection. If the native skill, CLI, or project index is unavailable, invoke `setup-workflow-environment`; do not substitute grep or pretend discovery was complete. diff --git a/.agents/skills/manage-project-work/assets/capture.md b/.agents/skills/manage-project-work/assets/capture.md index 560022d..6c82f9d 100644 --- a/.agents/skills/manage-project-work/assets/capture.md +++ b/.agents/skills/manage-project-work/assets/capture.md @@ -5,6 +5,7 @@ id: "{{CAPTURE_ID}}" title: "{{TITLE}}" status: pending created_at: "{{CREATED_AT}}" +awaits: agent source: {} claim_refs: [] resolution: null @@ -21,6 +22,14 @@ Record exact source paths, revisions, commands, outputs, and known limits. For intake or reconstruction material, retain fully qualified claim references in `claim_refs`; never cite raw files as authority. +# Who answers this + +State `awaits: maintainer` when the capture holds something only they can +settle — intent nobody recorded, a decision to confirm or revoke, a route +between materially different options. State `awaits: agent` when it is material +someone still has to place. A question filed as triage is a question nobody +knows they were asked. + # Why retained Explain why this material may affect a future product or engineering decision diff --git a/.claude/rules/evidence-first.md b/.claude/rules/evidence-first.md index f4db33e..e7afc11 100644 --- a/.claude/rules/evidence-first.md +++ b/.claude/rules/evidence-first.md @@ -57,4 +57,11 @@ Compare consumed CPU time against elapsed time, read what the job has written, and only then decide. Do not agree that something is broken because it was reported, and do not restart healthy work; that costs more than waiting. -Do not turn mocks, fixtures, fakes, placeholders, disabled checks, or partial wiring into production completion claims. Name missing evidence and unfinished work directly. +Do not turn mocks, fixtures, fakes, stories, showcase pages, benchmark +harnesses, placeholders, disabled checks, or partial wiring into production +completion claims. Behavior that exists only inside a demonstration surface is +not delivered, and behavior whose only caller is a test is implemented but not +verified — a green suite proves the test passed. Which of the two holds is a +question about what reaches the code, so it takes graph traversal and a recorded +query rather than a file listing. Name missing evidence and unfinished work +directly. diff --git a/.claude/rules/execution-continuity.md b/.claude/rules/execution-continuity.md index c338666..b758a25 100644 --- a/.claude/rules/execution-continuity.md +++ b/.claude/rules/execution-continuity.md @@ -18,8 +18,15 @@ to ask; it is evidence the search was not done. **A report is not the finish line.** Completion is defined by the issue's acceptance criteria and proven by its terminal status. A finished plan item, a written summary, a passing subtask, and a refreshed checkpoint are progress. -Never announce the next action and then stop: either take it, or record why it -cannot be taken. + +**End a turn only when you are waiting on the maintainer.** Ending one hands +control to them, so the test is what you are waiting for and not what you +wrote. Announcing a next action and stopping is the visible half of this; the +larger half announces nothing — "the work continues by itself", "the rest can +wait for the next boundary", a status report that names no blocker — and parks +just as completely, because nothing continues once the turn is over. If you are +not waiting on the maintainer, take the next action you can take alone. If you +are, name in one line what you need from them. **A discovery is not a stop.** Material that contradicts what was accepted is an entry in the discovery ledger, and the work continues. Stop only when the diff --git a/.claude/rules/maintainer-review.md b/.claude/rules/maintainer-review.md index 7da7321..87b9540 100644 --- a/.claude/rules/maintainer-review.md +++ b/.claude/rules/maintainer-review.md @@ -23,6 +23,58 @@ Present a compact review packet containing the exact decision, evidence, conflicts, recommendation, and requested response. Record approve, correct, or defer. Ask one focused question at a time. Silence is not approval. +## Write the decision for the product owner + +The maintainer decides what the product should be. They are not reading to +check the investigation, and they do not hold the file layout, the type names, +or yesterday's discovery numbering in their head. A packet written as if they +do forces them to reconstruct the product meaning out of engineering artifacts +before they can answer, and the usual outcome is not a wrong decision but no +decision at all. + +Order the packet so the decision is answerable from the top: + +1. **What happens today**, in the words a person using or operating the product + would use. No file paths, no symbol names, no identifiers the product's own + language does not contain. +2. **What is in doubt**, stated as a product question rather than an + implementation observation. +3. **What each answer means** — what gets recorded, what changes, what does not. + Reconstruction never edits source, so say plainly that no answer here fixes + anything by itself. +4. **The recommendation**, one option named, with its cost. +5. **The evidence**, last and clearly separable, addressed by claim or discovery + id for anyone who wants to audit it. + +Detail is not the problem and must not be dropped; its position is. Depth that +arrives before the question buries it, and the same depth below a stated +question supports it. + +Translate rather than cite. A term that exists only in the implementation gets +replaced by what it does for the product, with the original in parentheses at +most once. If a decision genuinely cannot be stated without engineering +vocabulary, that is a finding rather than a licence: the capability has no +product-level description yet, and recording that gap is part of the answer. + +One decision per packet. Several stacked in one message read as a status report +and get answered as none of them. + +Report waiting decisions by name and subject, or do not report them. Never by +count. "Eleven cases are gate-clean and none can close without your decision" +is accurate, reads as brevity, and works as concealment: it says nothing about +what the eleven contain, and the longer the queue the more it hides. Name each +one and what it turns on, one at a time; if that is too long for a single +message, that is a fact about the backlog, not a reason to compress it into a +number. + +Confirming a written record is the exception, and it is one decision rather than +many. When the material already carries the maintainer's own dated decisions, +ask whether they still stand — once for the record, with named exceptions — +instead of reopening each from scratch. Recovering intent that was never written +and confirming intent that was are different questions, and asking the harder +one when the easier one applies is how a body of accepted work becomes an +unanswered queue. + For significant work, record framing and completion decisions with `wfctl work approve <id> --stage framing|completion --by human:<maintainer-id>`. That command needs an interactive terminal, or an out-of-band `--token` matching diff --git a/.claude/rules/project-work-bundles.md b/.claude/rules/project-work-bundles.md index f68d634..f089941 100644 --- a/.claude/rules/project-work-bundles.md +++ b/.claude/rules/project-work-bundles.md @@ -41,3 +41,8 @@ Before completed closure, enumerate the entire bundle with `wfctl work context --stage review`, re-read every file, refresh stale receipts, reconcile every stable acceptance ID against direct evidence, promote durable truth separately, and obtain explicit maintainer completion approval. + +A capture is not automatically a chore. When it holds something only the +maintainer can settle, create it with `--awaits maintainer` so it appears in +their queue rather than in agent triage, and say so when reporting. A question +filed as triage is a question nobody knows they were asked. diff --git a/.claude/skills/align-project-knowledge/SKILL.md b/.claude/skills/align-project-knowledge/SKILL.md index 8832718..7d3cd0c 100644 --- a/.claude/skills/align-project-knowledge/SKILL.md +++ b/.claude/skills/align-project-knowledge/SKILL.md @@ -15,7 +15,11 @@ Do not design from code and memory alone. Establish the project's current intent invoke `setup-workflow-environment` to repair or reinstall the selected project/user skills, then ask only for the unavoidable agent-session restart. An on-disk file alone does not prove the running agent loaded it. -3. Run QMD from that knowledge root. Require `qmd status`; if QMD or the +3. Run QMD from that knowledge root. Require `qmd status`; if it reports + documents pending embedding, run `qmd embed` before relying on vector or + hybrid retrieval — indexing and embedding are separate, `qmd update` only + marks what needs vectors, and searching without them silently degrades to + lexical BM25 over exactly the material most recently written. If QMD or the project-local `.qmd/index.yml` is missing, stop and report the broken workflow environment. 4. Run `wfctl knowledge build --target <knowledge-root>`. Stop alignment if diff --git a/.claude/skills/explore-project-knowledge/SKILL.md b/.claude/skills/explore-project-knowledge/SKILL.md index 2ede90d..4e93f28 100644 --- a/.claude/skills/explore-project-knowledge/SKILL.md +++ b/.claude/skills/explore-project-knowledge/SKILL.md @@ -16,7 +16,9 @@ first broad discovery request in a session. 1. If the current repository has the `knowledge` profile, work from it. 2. If the current repository is a leaf, read `.workflow/config.json`, resolve its configured knowledge repository, and answer from that repository. -3. Require and invoke the native QMD skill. Check `qmd status`, then search only +3. Require and invoke the native QMD skill. Check `qmd status`, and when it + reports documents pending embedding run `qmd embed` first: without vectors + the search degrades to lexical BM25 over the newest material. Then search only the `knowledge` collection. If the native skill, CLI, or project index is unavailable, invoke `setup-workflow-environment`; do not substitute grep or pretend discovery was complete. diff --git a/.claude/skills/manage-project-work/assets/capture.md b/.claude/skills/manage-project-work/assets/capture.md index 560022d..6c82f9d 100644 --- a/.claude/skills/manage-project-work/assets/capture.md +++ b/.claude/skills/manage-project-work/assets/capture.md @@ -5,6 +5,7 @@ id: "{{CAPTURE_ID}}" title: "{{TITLE}}" status: pending created_at: "{{CREATED_AT}}" +awaits: agent source: {} claim_refs: [] resolution: null @@ -21,6 +22,14 @@ Record exact source paths, revisions, commands, outputs, and known limits. For intake or reconstruction material, retain fully qualified claim references in `claim_refs`; never cite raw files as authority. +# Who answers this + +State `awaits: maintainer` when the capture holds something only they can +settle — intent nobody recorded, a decision to confirm or revoke, a route +between materially different options. State `awaits: agent` when it is material +someone still has to place. A question filed as triage is a question nobody +knows they were asked. + # Why retained Explain why this material may affect a future product or engineering decision diff --git a/.gitignore b/.gitignore index 462ace7..57c80b1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ storybook-static/ # wfctl:begin graphify-out/ # wfctl:end + +# The stop guard's own state, which is per-session runtime bookkeeping rather than +# configuration. It lives beside the hook because `.workflow/current/` is where wfctl +# looks for work bindings and it parsed this as a malformed one. +.workflow/runtime/stop-guard.json diff --git a/.workflow/rules/evidence-first.md b/.workflow/rules/evidence-first.md index f4db33e..e7afc11 100644 --- a/.workflow/rules/evidence-first.md +++ b/.workflow/rules/evidence-first.md @@ -57,4 +57,11 @@ Compare consumed CPU time against elapsed time, read what the job has written, and only then decide. Do not agree that something is broken because it was reported, and do not restart healthy work; that costs more than waiting. -Do not turn mocks, fixtures, fakes, placeholders, disabled checks, or partial wiring into production completion claims. Name missing evidence and unfinished work directly. +Do not turn mocks, fixtures, fakes, stories, showcase pages, benchmark +harnesses, placeholders, disabled checks, or partial wiring into production +completion claims. Behavior that exists only inside a demonstration surface is +not delivered, and behavior whose only caller is a test is implemented but not +verified — a green suite proves the test passed. Which of the two holds is a +question about what reaches the code, so it takes graph traversal and a recorded +query rather than a file listing. Name missing evidence and unfinished work +directly. diff --git a/.workflow/rules/execution-continuity.md b/.workflow/rules/execution-continuity.md index c338666..b758a25 100644 --- a/.workflow/rules/execution-continuity.md +++ b/.workflow/rules/execution-continuity.md @@ -18,8 +18,15 @@ to ask; it is evidence the search was not done. **A report is not the finish line.** Completion is defined by the issue's acceptance criteria and proven by its terminal status. A finished plan item, a written summary, a passing subtask, and a refreshed checkpoint are progress. -Never announce the next action and then stop: either take it, or record why it -cannot be taken. + +**End a turn only when you are waiting on the maintainer.** Ending one hands +control to them, so the test is what you are waiting for and not what you +wrote. Announcing a next action and stopping is the visible half of this; the +larger half announces nothing — "the work continues by itself", "the rest can +wait for the next boundary", a status report that names no blocker — and parks +just as completely, because nothing continues once the turn is over. If you are +not waiting on the maintainer, take the next action you can take alone. If you +are, name in one line what you need from them. **A discovery is not a stop.** Material that contradicts what was accepted is an entry in the discovery ledger, and the work continues. Stop only when the diff --git a/.workflow/rules/maintainer-review.md b/.workflow/rules/maintainer-review.md index 7da7321..87b9540 100644 --- a/.workflow/rules/maintainer-review.md +++ b/.workflow/rules/maintainer-review.md @@ -23,6 +23,58 @@ Present a compact review packet containing the exact decision, evidence, conflicts, recommendation, and requested response. Record approve, correct, or defer. Ask one focused question at a time. Silence is not approval. +## Write the decision for the product owner + +The maintainer decides what the product should be. They are not reading to +check the investigation, and they do not hold the file layout, the type names, +or yesterday's discovery numbering in their head. A packet written as if they +do forces them to reconstruct the product meaning out of engineering artifacts +before they can answer, and the usual outcome is not a wrong decision but no +decision at all. + +Order the packet so the decision is answerable from the top: + +1. **What happens today**, in the words a person using or operating the product + would use. No file paths, no symbol names, no identifiers the product's own + language does not contain. +2. **What is in doubt**, stated as a product question rather than an + implementation observation. +3. **What each answer means** — what gets recorded, what changes, what does not. + Reconstruction never edits source, so say plainly that no answer here fixes + anything by itself. +4. **The recommendation**, one option named, with its cost. +5. **The evidence**, last and clearly separable, addressed by claim or discovery + id for anyone who wants to audit it. + +Detail is not the problem and must not be dropped; its position is. Depth that +arrives before the question buries it, and the same depth below a stated +question supports it. + +Translate rather than cite. A term that exists only in the implementation gets +replaced by what it does for the product, with the original in parentheses at +most once. If a decision genuinely cannot be stated without engineering +vocabulary, that is a finding rather than a licence: the capability has no +product-level description yet, and recording that gap is part of the answer. + +One decision per packet. Several stacked in one message read as a status report +and get answered as none of them. + +Report waiting decisions by name and subject, or do not report them. Never by +count. "Eleven cases are gate-clean and none can close without your decision" +is accurate, reads as brevity, and works as concealment: it says nothing about +what the eleven contain, and the longer the queue the more it hides. Name each +one and what it turns on, one at a time; if that is too long for a single +message, that is a fact about the backlog, not a reason to compress it into a +number. + +Confirming a written record is the exception, and it is one decision rather than +many. When the material already carries the maintainer's own dated decisions, +ask whether they still stand — once for the record, with named exceptions — +instead of reopening each from scratch. Recovering intent that was never written +and confirming intent that was are different questions, and asking the harder +one when the easier one applies is how a body of accepted work becomes an +unanswered queue. + For significant work, record framing and completion decisions with `wfctl work approve <id> --stage framing|completion --by human:<maintainer-id>`. That command needs an interactive terminal, or an out-of-band `--token` matching diff --git a/.workflow/rules/project-work-bundles.md b/.workflow/rules/project-work-bundles.md index f68d634..f089941 100644 --- a/.workflow/rules/project-work-bundles.md +++ b/.workflow/rules/project-work-bundles.md @@ -41,3 +41,8 @@ Before completed closure, enumerate the entire bundle with `wfctl work context --stage review`, re-read every file, refresh stale receipts, reconcile every stable acceptance ID against direct evidence, promote durable truth separately, and obtain explicit maintainer completion approval. + +A capture is not automatically a chore. When it holds something only the +maintainer can settle, create it with `--awaits maintainer` so it appears in +their queue rather than in agent triage, and say so when reporting. A question +filed as triage is a question nobody knows they were asked. diff --git a/.workflow/runtime/guard-stop.mjs b/.workflow/runtime/guard-stop.mjs index d70c9d2..2d17e73 100644 --- a/.workflow/runtime/guard-stop.mjs +++ b/.workflow/runtime/guard-stop.mjs @@ -1,10 +1,15 @@ #!/usr/bin/env node -// Stop hook. A turn that ends on a stated next action is the most common way -// autonomous work dies: nothing is blocked, nothing failed, and the transcript -// simply stops. Instructions do not fix it — the managed agent block already -// says "announce it and continue" and is ignored. What fixes it is costing the -// model another turn, because inside that turn the announced action is the -// cheapest thing to do. +// Stop hook. Autonomous work dies when a turn ends while nothing is blocked: +// nothing failed, the transcript simply stops, and hours pass before anyone +// notices. Instructions do not fix it — the managed agent block already says to +// continue and is ignored. What fixes it is costing the model another turn, +// because inside that turn the next action is the cheapest thing to do. +// +// The question asked is whether the agent is waiting on the maintainer, not +// what its last message said. Framing it around a stated next action missed the +// larger half of the failure: a turn that ends on "the work continues by +// itself" or "the rest can wait" announces nothing, blocks on nothing, and +// parks just as completely. // // This never decides whether the work is done. It reports what the turn ended // with and what the repository says is outstanding, and hands the judgment @@ -26,7 +31,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; const MESSAGE_LIMIT = 600; -const MAX_REENTRIES = 6; +const MAX_REENTRIES = 100; function allow() { process.exit(0); @@ -104,10 +109,13 @@ function main() { return; } if (carried.count >= MAX_REENTRIES) { - // State that keeps moving for reasons unrelated to this turn would - // otherwise re-enter forever. Observed live: a stub whose counter - // advanced on every read kept a blocked agent restating the same refusal - // thirteen times before the ceiling ended it. + // A runaway backstop and nothing more. It was six, chosen from a rigged + // test where the state moved on its own while the agent was stuck, and it + // became the only bound that ever fired: a productive overnight run hit + // it after six re-entries and parked for nine hours with work left. The + // two content bounds above are the real ones — unchanged state and a + // repeated answer both mean the next re-entry buys nothing — so this only + // has to guarantee the turn ends. writeMemory(cwd, { key, count: 0, fingerprint, answer }); allow(); return; @@ -150,7 +158,7 @@ function stateFingerprint(report) { } function memoryPath(cwd) { - return join(cwd, ".workflow/current/stop-guard.json"); + return join(cwd, ".workflow/runtime/stop-guard.json"); } function readMemory(cwd) { @@ -196,14 +204,18 @@ function reason(message, awaiting) { "The repository reports work awaiting the agent:", outstanding, "", - "If that text stated a next action that was not taken, take it now.", - "Continue while there is work you can do without the maintainer; this check", - "keeps returning as long as each turn moves the repository, and releases on", - "the first turn that does not.", + "Ending a turn hands control to the maintainer, so the question is not what", + "your last message said. It is whether you are waiting on them. If you are", + "not, you have not finished: take the next action you can take alone. That", + "includes an action you named, and equally one you never mentioned.", + "", + "If you are waiting on them, name in one line what you need and end. \"The", + "work continues by itself\" is not that line — nothing continues once the", + "turn is over.", "", - "If the outstanding work genuinely needs the maintainer, say what you need", - "from them in one line and end. Do not acknowledge this check, agree with", - "it, explain yourself, or answer with an empty turn.", + "This check returns while each turn moves the repository and releases on the", + "first turn that does not. Do not acknowledge it, agree with it, explain", + "yourself, or answer with an empty turn.", ].join("\n"); } diff --git a/.workflow/state.json b/.workflow/state.json index fc8ff6a..6cd44f3 100644 --- a/.workflow/state.json +++ b/.workflow/state.json @@ -4,16 +4,16 @@ "profile": "leaf", "files": { ".claude/rules/evidence-first.md": { - "sha256": "4adf35cd60692e32728b5de325fc40637cee634a8b013ff434650ebe5b78c07c" + "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" }, ".claude/rules/execution-continuity.md": { - "sha256": "746dc5804137757f058e2e04d097fcad7c4a2277eb2f073045b9ab5e14917842" + "sha256": "2f156cedf6773cbb17357d755d404141936f5acc4fd7a4a33f0fc2a3068b9d8b" }, ".claude/rules/maintainer-review.md": { - "sha256": "429a3a7556703b51f06c36f81ef6fa96c147b80e3f616f086f9904c39a2d7499" + "sha256": "5bae3c18f1cec779f1b9e9f8842127a9afe58ac37a47562890be14872f0741ce" }, ".claude/rules/project-work-bundles.md": { - "sha256": "ea5088c79442f080c0f03a4fffd46672806e869dec6433757d8f8641254da680" + "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" }, ".claude/rules/project-work-lifecycle.md": { "sha256": "ac5212a9f8453175007b5bdb0b587e6aca47648d6273ae2cdb0b014d9365ea14" @@ -25,16 +25,16 @@ "sha256": "61bff3ecb1270f462919509a371a296d1dc155a9e7306931191cdb8584053532" }, ".workflow/rules/evidence-first.md": { - "sha256": "4adf35cd60692e32728b5de325fc40637cee634a8b013ff434650ebe5b78c07c" + "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" }, ".workflow/rules/execution-continuity.md": { - "sha256": "746dc5804137757f058e2e04d097fcad7c4a2277eb2f073045b9ab5e14917842" + "sha256": "2f156cedf6773cbb17357d755d404141936f5acc4fd7a4a33f0fc2a3068b9d8b" }, ".workflow/rules/maintainer-review.md": { - "sha256": "429a3a7556703b51f06c36f81ef6fa96c147b80e3f616f086f9904c39a2d7499" + "sha256": "5bae3c18f1cec779f1b9e9f8842127a9afe58ac37a47562890be14872f0741ce" }, ".workflow/rules/project-work-bundles.md": { - "sha256": "ea5088c79442f080c0f03a4fffd46672806e869dec6433757d8f8641254da680" + "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" }, ".workflow/rules/project-work-lifecycle.md": { "sha256": "ac5212a9f8453175007b5bdb0b587e6aca47648d6273ae2cdb0b014d9365ea14" @@ -46,7 +46,7 @@ "sha256": "0bb95f5bc34cb2ee9c3c453a171cd36f60d8c7d4eeb283ea07e8ad91d5b3ef93" }, ".workflow/runtime/guard-stop.mjs": { - "sha256": "118f46237d670e61cacf7aa44d50d8ce8d43acdab26dc52b6dfefdf7f257d27f" + "sha256": "4e3068598e0384e7846727e38b755ab0e3f33418fb775a9bb4d3fb34b8cd0bad" }, ".workflow/runtime/idle-guard.sh": { "sha256": "8a132664ff43cc732e4da14b33e7b72f89e5facbb26169b3135eac2c64390da2" diff --git a/AGENTS.md b/AGENTS.md index c30350a..9a3614a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,15 +29,18 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und Outside bootstrap or explicit troubleshooting, never require them to know a subcommand, record ID, generated path, QMD query, Graphify invocation, or structured-file schema. Resolve those mechanics yourself. -- When internal state offers one safe valid continuation, announce it and - continue in the same turn. Ending a turn on a stated next action is the - announcement without the continuation: take the action, or record why it - cannot be taken. A written report is progress and never the finish line; - completion is the terminal status of the required records. This holds while - executing accepted work and not while shaping or specifying, where asking is - the work. When several materially different choices remain, present their - human meaning, evidence, and recommendation; after the maintainer chooses, - execute the corresponding commands yourself. +- End a turn only when you are waiting on the maintainer. Ending one hands + control to them, so the test is not whether you announced anything: a turn + that closes with "the work continues" or "the rest can wait" parks just as + completely as one that names a next step and abandons it. If you are not + waiting on them, take the next action you can take alone, in the same turn. + When you are waiting, say in one line what you need. A written report is + progress and never the finish line; completion is the terminal status of the + required records. This holds while executing accepted work and not while + shaping or specifying, where asking is the work. When several materially + different choices remain, present their human meaning, evidence, and + recommendation; after the maintainer chooses, execute the corresponding + commands yourself. - For significant multi-turn work, create the central bundle early. After every material maintainer turn or agent investigation cycle, preserve consequential new understanding in the owning record's broad `Discovery @@ -62,7 +65,11 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und memory. - Use `changes/inbox/` only for pending captures that have no active or curated owner. Resolve each capture to existing destinations or discard it with a - reason; never duplicate active progress there. + reason; never duplicate active progress there. The brief names every pending + capture, so an unresolved one is not unknown to you — a queue that grows + without anyone opening it is the same as no queue. A capture only the + maintainer can settle is created with `--awaits maintainer` and presented to + them as one decision at a time, not listed as a backlog. - Do not create a competing leaf-local spec or issue tracker. Claim one central frontier issue from the exact bound checkout before implementation. Before completion, account for every bundle file at its current hash; a receipt diff --git a/skills-lock.json b/skills-lock.json index 0d8cd56..09c7e20 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -4,7 +4,7 @@ "align-project-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "c1b061e5bc416f8afc1f9265e67196a9e9b0de767e1957d383714119933bb846" + "computedHash": "961662c0da125d6948f1e1714d5c14fc2357c482b9c623bb35add49c54d48af7" }, "analyze-with-graphify": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", @@ -29,7 +29,7 @@ "explore-project-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "60b8753a3c9e0cb30550313dde39530d41cac669da6894cf2a32fa2fb23b9760" + "computedHash": "02a871fdc9ccc4086df980b6c29f31792a4c8246155cf57980a898e26a87c92f" }, "implement-work-item": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", @@ -39,7 +39,7 @@ "manage-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "d3bb7dfc51433c2d6f5a9b7d0f375fae6ec3a7253b47473e59b3fd55b4131327" + "computedHash": "b83becc7b3d6bfb4b1d074c48c53b5d551bca27107a175a804de1206f1c6cdce" }, "qmd": { "source": "/Users/lukachisama/.bun/install/global/node_modules/@tobilu/qmd/skills/qmd", From 8a689e27f47ab27cb83e14c91aee1eab851361c8 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:15:33 +0300 Subject: [PATCH 054/124] feat(liquid): show the contract action, with where each value came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four facts the wallet established and the protocol's own words are on one screen, each labelled with its origin in words rather than a badge — "claimed by the site" is the thing that has to be unmistakable, and a badge is the thing people stop seeing. They are not split into a summary and a detail screen. The distinction that matters here is not importance but authorship, and a first screen reads as the part that counts. Every value goes through one component that takes a provenanced value and nothing else, so an unattributed one cannot reach the surface without someone changing that signature. --- .../ProcessCtConfirmation.test.tsx | 61 ++++++++ .../ProcessCtConfirmation.tsx | 147 ++++++++++++++++++ .../processConfidentialTransaction/index.ts | 3 +- apps/extension/src/notification/index.tsx | 7 +- 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx create mode 100644 apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx new file mode 100644 index 0000000..cafe17c --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { computed, fromSite, verified } from "../../../domain/manifest/provenance"; +import { + isProcessCtConfirmationData, + PROCESS_CT_CONFIRMATION_KIND, + processCtConfirmationRenderer, +} from "./ProcessCtConfirmation"; + +// AC-06 and AC-07 at the surface. What is checked here is what the surface is handed and +// what it will accept — the rendering itself is JSX with no branching worth asserting, and +// the property that matters is enforced by the type: every value it displays is provenanced, +// so an unattributed one cannot reach it. + +const MODEL = { + account: computed("liquid:testnet account 0"), + action: fromSite("Receive"), + covenants: [ + { + address: verified("tex1p_derived"), + utxoType: fromSite("p2pk_output"), + verified: computed(true), + }, + ], + feeSats: computed(500n), + netEffect: [{ asset: computed("lbtc"), sats: computed(-50_500n) }], + protocol: fromSite("p2pk-simplicity"), + summary: fromSite("Spend a p2pk output back into your wallet."), +}; + +describe("the contract-action confirmation", () => { + test("recognises the payload the method builds", () => { + expect( + isProcessCtConfirmationData({ + broadcast: false, + kind: PROCESS_CT_CONFIRMATION_KIND, + shown: MODEL, + }), + ).toBe(true); + }); + + test("and refuses anything else, so the host falls back rather than rendering it wrong", () => { + expect(isProcessCtConfirmationData({ kind: "liquid.signPset" })).toBe(false); + expect(isProcessCtConfirmationData(null)).toBe(false); + expect(isProcessCtConfirmationData({ kind: PROCESS_CT_CONFIRMATION_KIND })).toBe(false); + }); + + test("is registered under the kind the method puts on the payload", () => { + expect(processCtConfirmationRenderer.kind).toBe(PROCESS_CT_CONFIRMATION_KIND); + }); + + test("renders nothing for a payload that is not its own", () => { + expect( + processCtConfirmationRenderer.render({ + onConfirm: () => {}, + onDecline: () => {}, + request: { data: { kind: "something.else" } } as never, + }), + ).toBeNull(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx new file mode 100644 index 0000000..f1583cb --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx @@ -0,0 +1,147 @@ +import type { ConfirmationRenderer } from "@/common/Confirmation"; +import { UiButton } from "@/ui/UiButton/base"; + +import { type ConfirmationModel, describeOrigin } from "../../../domain/manifest/confirmation"; +import type { Provenanced } from "../../../domain/manifest/provenance"; + +/** What the method puts on the confirmation payload, and how this surface recognises it. */ +export const PROCESS_CT_CONFIRMATION_KIND = "liquid.processConfidentialTransaction"; + +export type ProcessCtConfirmationData = { + broadcast: boolean; + kind: typeof PROCESS_CT_CONFIRMATION_KIND; + shown: ConfirmationModel; +}; + +export function isProcessCtConfirmationData(value: unknown): value is ProcessCtConfirmationData { + return ( + typeof value === "object" && + value !== null && + (value as { kind?: unknown }).kind === PROCESS_CT_CONFIRMATION_KIND && + typeof (value as { shown?: unknown }).shown === "object" + ); +} + +/** + * One value, shown with where it came from. + * + * Every value on this surface goes through here, which is the whole point: the component + * takes a provenanced value and nothing else, so a plain string cannot be rendered without + * someone changing this signature. The origin is words rather than a colour or a badge, + * because "claimed by the site" is the thing that has to be unmistakable and a badge is the + * thing people stop seeing. + */ +function Shown({ label, value }: { label: string; value: Provenanced<string> }) { + return ( + <div className="flex flex-col gap-1"> + <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> + {label} + </span> + <span className="text-sm font-medium break-all">{value.value}</span> + <span className="text-muted-foreground text-xs">{describeOrigin(value.origin)}</span> + </div> + ); +} + +/** Base units as a person reads them, keeping the sign that says which way the money goes. */ +function amount(sats: bigint): string { + const negative = sats < 0n; + const whole = (negative ? -sats : sats).toString().padStart(9, "0"); + const point = `${whole.slice(0, -8)}.${whole.slice(-8)}`.replace(/\.?0+$/, ""); + + return `${negative ? "−" : "+"}${point || "0"} L-BTC`; +} + +/** + * What a person is asked to approve before a contract action is signed. + * + * The four facts the wallet established for itself come first and the protocol's own words + * come after, each labelled with where it came from. They are not separated into two + * screens deliberately: a first screen reads as the summary and a second as the detail, and + * the distinction that matters here is not importance but authorship. + */ +export function ProcessCtConfirmation({ + data, + onConfirm, + onDecline, +}: { + data: ProcessCtConfirmationData; + onConfirm: () => void; + onDecline: () => void; +}) { + const { shown } = data; + + return ( + <div className="bg-background text-foreground flex size-full flex-col"> + <header className="p-4 pb-3 text-center"> + <h2 className="cn-font-heading text-xl font-bold">Perform a contract action?</h2> + <p className="text-muted-foreground mt-1 text-sm"> + Nothing is signed until you agree, and what you agree to is what gets signed. + </p> + </header> + + <div className="flex-1 space-y-5 overflow-y-auto px-4"> + {shown.netEffect.map((effect) => ( + <div className="flex flex-col gap-1" key={effect.asset.value}> + <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> + This wallet + </span> + <span className="text-lg font-semibold">{amount(effect.sats.value)}</span> + <span className="text-muted-foreground text-xs"> + {describeOrigin(effect.sats.origin)} + </span> + </div> + ))} + + <Shown + label="Network fee" + value={{ ...shown.feeSats, value: amount(shown.feeSats.value) } as Provenanced<string>} + /> + <Shown label="Acting account" value={shown.account} /> + + {shown.covenants.map((covenant) => ( + <div className="flex flex-col gap-1" key={covenant.address.value}> + <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> + {covenant.verified.value ? "Contract, checked" : "Contract, not yet on chain"} + </span> + <span className="font-mono text-xs break-all">{covenant.address.value}</span> + <span className="text-muted-foreground text-xs"> + {describeOrigin(covenant.address.origin)} + </span> + </div> + ))} + + {/* Everything below is the site's own words. It is shown because a person deciding + needs to know what the site says it is doing — and labelled, because the wallet + checked none of it. */} + <Shown label="Protocol" value={shown.protocol} /> + <Shown label="Action" value={shown.action} /> + {shown.summary === undefined ? null : ( + <Shown label="What the site says this does" value={shown.summary} /> + )} + </div> + + <div className="flex items-center gap-3 p-4 pt-3"> + <UiButton type="button" variant="outline" className="flex-1" onClick={onDecline}> + Decline + </UiButton> + <UiButton type="button" className="flex-1" onClick={onConfirm}> + {data.broadcast ? "Sign and send" : "Sign"} + </UiButton> + </div> + </div> + ); +} + +/** Plugs this confirmation into the generic host (see ConfirmProvider). */ +export const processCtConfirmationRenderer: ConfirmationRenderer = { + kind: PROCESS_CT_CONFIRMATION_KIND, + render: ({ onConfirm, onDecline, request }) => + isProcessCtConfirmationData(request.data) ? ( + <ProcessCtConfirmation + data={request.data} + onConfirm={() => onConfirm()} + onDecline={onDecline} + /> + ) : null, +}; diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 470f4b6..192ebe7 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -24,6 +24,7 @@ import type { ParsedLiquidProcessCtParams } from "../../../domain/manifest/types import { parseLiquidProcessCtParams } from "../../../domain/manifest/validation"; import type { LiquidWalletBackend } from "../../backends/LiquidWalletBackend"; import { resolveDappAccount } from "../../dappAccountScope"; +import { PROCESS_CT_CONFIRMATION_KIND } from "./ProcessCtConfirmation"; export type LiquidProcessCtContext = WalletRpcBaseContext & { chain: LiquidChainRecord; @@ -116,7 +117,7 @@ export const createProcessLiquidConfidentialTransaction = ( // `not-yet-on-chain` marks one being created, which there is nothing to compare // against — it is a different fact, not a weaker form of verified. covenants: review.covenants, - kind: "liquid.processConfidentialTransaction", + kind: PROCESS_CT_CONFIRMATION_KIND, protocol: review.protocol, }, message: `A site wants to perform "${review.action}" on the ${review.protocol} protocol.`, diff --git a/apps/extension/src/notification/index.tsx b/apps/extension/src/notification/index.tsx index f9512c9..780b6e7 100644 --- a/apps/extension/src/notification/index.tsx +++ b/apps/extension/src/notification/index.tsx @@ -11,6 +11,7 @@ import type { PegasusMsgProtocolMap } from "@/background"; import { ConfirmProvider } from "@/common/Confirmation"; import { AppErrorBoundary } from "@/components/AppErrorBoundary"; import { ThemeProvider } from "@/contexts/ThemeProvider"; +import { processCtConfirmationRenderer } from "@/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation"; import { dappAddChainConfirmationRenderer } from "@/core/extension-background/dapp-authorization/DappAddChainConfirmation"; import { dappConnectConfirmationRenderer } from "@/core/extension-background/dapp-authorization/DappConnectConfirmation"; import { dappSwitchChainConfirmationRenderer } from "@/core/extension-background/dapp-authorization/DappSwitchChainConfirmation"; @@ -28,12 +29,14 @@ if (!rootElement) { throw new Error("Notification root element was not found"); } -// Confirmations shown in the notification window: the generic host + the dapp renderers (connect, -// add-chain, switch-chain). +// Confirmations shown in the notification window: the generic host + the dapp renderers +// (connect, add-chain, switch-chain) and the contract action, which is the one that shows +// values alongside where each of them came from. const confirmationRenderers = [ dappConnectConfirmationRenderer, dappAddChainConfirmationRenderer, dappSwitchChainConfirmationRenderer, + processCtConfirmationRenderer, ]; createRoot(rootElement).render( From 404dd737d3ccdb143a42b2f4d96887da7521b93f Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:18:43 +0300 Subject: [PATCH 055/124] test(liquid): measure that the transaction balances, and that the declared mode reaches the compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two criteria I had put on the live run and that did not belong there. The wallet's fee estimate and the figure the module charges are different numbers, so what has to hold is not that they agree but that the transaction balances against whichever is charged. Built through the real module: the charged fee covers what the wallet did not pay out, an over-estimate leaves it payable rather than short, and paying out everything is refused rather than producing something the network would drop. And the mode a protocol declares reaches the compiler rather than stopping at a flag — asserted for declared-on, declared-off and declared-nothing, with nothing in the request or the wallet able to change it. --- .../adapters/smplx/loadSmplxWasm.test.ts | 66 +++++++++++++++++++ .../liquid/domain/manifest/review.test.ts | 45 +++++++++++++ 2 files changed, 111 insertions(+) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 90a8263..acd2d3c 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js"; +import { estimateFeeSats } from "../../domain/manifest/fee"; import { guardSpentInputs } from "../../domain/manifest/inputGuard"; import { spentInputs } from "../../domain/manifest/spentInputs"; @@ -790,3 +791,68 @@ describe("the simplicity-lending contracts", () => { ); }); }); + +// AC-09's second clause, measured rather than reasoned about. The wallet's estimate and the +// fee the module charges are different numbers — one is a model of an unsigned shape and the +// other the weight of a signed transaction — so what has to hold is that the transaction +// balances against whichever one is charged, whatever the estimate said. +describe("a transaction balances against the fee that is charged", () => { + const TXID = "5".repeat(64); + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + + function txOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const value = sats.toString(16).padStart(16, "0"); + const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${len}${scriptHex}`; + } + + /** Funds `funded`, pays `paid`, and reports what the module charged for it. */ + function build(funded: bigint, paid: bigint, rate: number) { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + + try { + builder.addWalletInput(TXID, 0, txOut(funded, signer.scriptPubKeyHex()), undefined); + builder.addOutput(signer.scriptPubKeyHex(), paid, POLICY_ASSET); + + const signed = signer.finalizeTransaction(builder, rate, signer.scriptPubKeyHex()); + const fee = signed.feeSats; + + signed.free(); + + return fee; + } finally { + builder.free(); + signer.free(); + } + } + + // The wallet plans an output as "what this input holds, less the fee", using its own + // estimate. Whatever that estimate was, the module charges its own figure and makes the + // transaction balance — which is why an estimate that is merely close is safe. + test("the charged fee covers the difference the wallet did not pay out", () => { + const funded = 100_000n; + const estimated = estimateFeeSats({ covenantInputs: 0, outputs: 1, walletInputs: 1 }, 1000); + const charged = build(funded, funded - estimated, 1000); + + expect(charged > 0n).toBe(true); + expect(charged <= estimated).toBe(true); + }); + + // Over-estimating is the safe direction: the surplus returns as change rather than + // leaving the transaction short. + test("an over-estimate leaves the transaction payable rather than short", () => { + const funded = 100_000n; + const generous = estimateFeeSats({ covenantInputs: 2, outputs: 3, walletInputs: 3 }, 1000); + + expect(() => build(funded, funded - generous, 1000)).not.toThrow(); + }); + + // Under-paying the fee is what the wallet must never do, and the module refuses it rather + // than producing a transaction the network would drop. + test("paying out everything leaves nothing for the fee, and is refused", () => { + expect(() => build(100_000n, 100_000n, 1000)).toThrow(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts index d51e59e..da83dd6 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts +++ b/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts @@ -487,3 +487,48 @@ describe("what the person is shown", () => { expect((await shown()).covenants[0]?.utxoType.origin).toBe("site"); }); }); + +// AC-15 through the whole path: the wallet builds each contract the way its own protocol +// declares, and the declaration reaches the compiler rather than stopping at a flag. +describe("the mode a protocol declares reaches the compiler", () => { + function withMode(compile_debug_symbols?: boolean) { + const seen: boolean[] = []; + const manifest = { + ...(p2pkManifest as unknown as Record<string, unknown>), + ...(compile_debug_symbols === undefined ? {} : { compile_debug_symbols }), + }; + + return reviewManifestAction(request({ manifest }), { + ...deps, + compile: (input) => { + seen.push(input.includeDebugSymbols); + + return DERIVED; + }, + readTxOut: readTxOut("unused"), + }).then((result) => ({ result, seen })); + } + + test("a protocol declaring debug symbols is built with them", async () => { + const { result, seen } = await withMode(true); + + expect(isRefusal(result)).toBe(false); + expect(seen).toEqual([true]); + }); + + test("one declaring them off is built without them", async () => { + expect((await withMode(false)).seen).toEqual([false]); + }); + + test("one declaring nothing is built plainly", async () => { + expect((await withMode()).seen).toEqual([false]); + }); + + // No setting governs this and none exists: the only thing that decides is the document. + test("nothing in the request or the wallet can change it", async () => { + const declared = await withMode(true); + const plain = await withMode(); + + expect(declared.seen).not.toEqual(plain.seen); + }); +}); From 92968e6aff93faa402b3e111287c427197619639 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:40:50 +0300 Subject: [PATCH 056/124] refactor(manifest): make the runtime a package, and give it a surface to run from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime read manifests for one wallet from inside that wallet's tree, so nothing but that wallet could use it and nothing but that wallet could show it working. Both were accidents of where the files sat. `@humid/tx-manifest` holds it now. The move cost one seam: the request validator threw the extension's RPC error, and a package with no transport cannot own how a refusal travels, so it returns the malformed request as a value and the method that does have a transport wraps it. The standing check that nothing reaches outside the runtime loses its one listed exception as a result. The web dashboard card claimed the method "returns a structured not_implemented error" — six slices stale — and offered a raw JSON textarea, which is not a surface anyone can run a protocol from. It now assembles the six-part request around the published p2pk manifest and its contract source. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../adapters/smplx/loadSmplxWasm.test.ts | 9 ++- .../ProcessCtConfirmation.test.tsx | 3 +- .../ProcessCtConfirmation.tsx | 6 +- .../index.test.ts | 5 +- .../processConfidentialTransaction/index.ts | 49 +++++++++----- apps/web/package.json | 1 + .../components/method-cards/ProcessCtCard.tsx | 66 +++++++++++++++++-- apps/web/src/app/dashboard/contracts/p2pk.ts | 13 ++++ bun.lock | 12 ++++ package.json | 1 + packages/tx-manifest/package.json | 16 +++++ .../tx-manifest/src}/__fixtures__/README.md | 0 .../src}/__fixtures__/contracts/README.md | 0 .../__fixtures__/contracts/asset_auth.simf | 0 .../contracts/asset_auth_vault.simf | 0 .../contracts/issuance_factory.simf | 0 .../src}/__fixtures__/contracts/lending.simf | 0 .../__fixtures__/contracts/script_auth.simf | 0 .../src}/__fixtures__/dex.manifest.json | 0 .../src}/__fixtures__/last_will.manifest.json | 0 .../src}/__fixtures__/lending.manifest.json | 0 .../__fixtures__/lending_v2.manifest.json | 0 .../__fixtures__/lending_v3.manifest.json | 0 .../__fixtures__/p2pk-grouped.manifest.json | 0 .../src}/__fixtures__/p2pk.manifest.json | 0 .../tx-manifest/src}/__fixtures__/p2pk.simf | 0 .../src}/__fixtures__/zeroconf.manifest.json | 0 .../tx-manifest/src}/chainRead.test.ts | 0 .../tx-manifest/src}/chainRead.ts | 0 .../tx-manifest/src}/coinSelection.test.ts | 0 .../tx-manifest/src}/coinSelection.ts | 0 .../tx-manifest/src}/compileParams.test.ts | 0 .../tx-manifest/src}/compileParams.ts | 0 .../tx-manifest/src}/computed.test.ts | 0 .../tx-manifest/src}/computed.ts | 0 .../tx-manifest/src}/confirmation.ts | 0 .../tx-manifest/src}/corpus.test.ts | 0 .../tx-manifest/src}/covenant.test.ts | 0 .../tx-manifest/src}/covenant.ts | 0 .../tx-manifest/src}/covenantHash.test.ts | 0 .../tx-manifest/src}/covenantHash.ts | 0 .../tx-manifest/src}/encode.test.ts | 0 .../tx-manifest/src}/encode.ts | 0 .../tx-manifest/src}/evaluate.test.ts | 0 .../tx-manifest/src}/evaluate.ts | 0 .../tx-manifest/src}/fee.test.ts | 0 .../tx-manifest/src}/fee.ts | 0 packages/tx-manifest/src/index.ts | 32 +++++++++ .../tx-manifest/src}/inputGuard.test.ts | 0 .../tx-manifest/src}/inputGuard.ts | 0 .../tx-manifest/src}/inputRules.test.ts | 0 .../tx-manifest/src}/inputRules.ts | 0 .../tx-manifest/src}/json.ts | 0 .../tx-manifest/src}/normalise.test.ts | 0 .../tx-manifest/src}/normalise.ts | 0 .../tx-manifest/src}/plan.test.ts | 0 .../tx-manifest/src}/plan.ts | 0 .../tx-manifest/src}/provenance.test.ts | 0 .../tx-manifest/src}/provenance.ts | 0 .../tx-manifest/src}/references.test.ts | 0 .../tx-manifest/src}/references.ts | 0 .../tx-manifest/src}/refuse.test.ts | 0 .../tx-manifest/src}/refuse.ts | 0 .../tx-manifest/src}/registry.test.ts | 0 .../tx-manifest/src}/registry.ts | 0 .../tx-manifest/src}/requirements.test.ts | 0 .../tx-manifest/src}/requirements.ts | 0 .../tx-manifest/src}/review.test.ts | 0 .../tx-manifest/src}/review.ts | 0 .../tx-manifest/src}/sites.ts | 0 .../tx-manifest/src}/spentInputs.ts | 0 .../tx-manifest/src}/stateless.test.ts | 8 +-- .../tx-manifest/src}/types.ts | 0 .../tx-manifest/src}/validate.ts | 0 .../tx-manifest/src}/validation.test.ts | 22 +++++-- .../tx-manifest/src}/validation.ts | 36 ++++++---- 76 files changed, 228 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/app/dashboard/contracts/p2pk.ts create mode 100644 packages/tx-manifest/package.json rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/README.md (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/contracts/README.md (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/contracts/asset_auth.simf (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/contracts/asset_auth_vault.simf (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/contracts/issuance_factory.simf (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/contracts/lending.simf (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/contracts/script_auth.simf (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/dex.manifest.json (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/last_will.manifest.json (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/lending.manifest.json (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/lending_v2.manifest.json (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/lending_v3.manifest.json (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/p2pk-grouped.manifest.json (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/p2pk.manifest.json (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/p2pk.simf (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/__fixtures__/zeroconf.manifest.json (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/chainRead.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/chainRead.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/coinSelection.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/coinSelection.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/compileParams.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/compileParams.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/computed.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/computed.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/confirmation.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/corpus.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/covenant.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/covenant.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/covenantHash.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/covenantHash.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/encode.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/encode.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/evaluate.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/evaluate.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/fee.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/fee.ts (100%) create mode 100644 packages/tx-manifest/src/index.ts rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/inputGuard.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/inputGuard.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/inputRules.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/inputRules.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/json.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/normalise.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/normalise.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/plan.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/plan.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/provenance.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/provenance.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/references.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/references.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/refuse.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/refuse.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/registry.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/registry.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/requirements.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/requirements.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/review.test.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/review.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/sites.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/spentInputs.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/stateless.test.ts (91%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/types.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/validate.ts (100%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/validation.test.ts (54%) rename {apps/extension/src/core/chains/liquid/domain/manifest => packages/tx-manifest/src}/validation.ts (53%) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index acd2d3c..bb47674 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -2,12 +2,11 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; +import { estimateFeeSats } from "@humid/tx-manifest"; +import { guardSpentInputs } from "@humid/tx-manifest"; +import { spentInputs } from "@humid/tx-manifest"; import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js"; -import { estimateFeeSats } from "../../domain/manifest/fee"; -import { guardSpentInputs } from "../../domain/manifest/inputGuard"; -import { spentInputs } from "../../domain/manifest/spentInputs"; - // Exercises the exact bindings `loadSmplxWasm` consumes. The only difference is where // the module bytes come from: the extension fetches them through a Vite asset URL, this // reads them off disk. Everything after instantiation — the `__wbg_set_wasm` handshake, @@ -694,7 +693,7 @@ describe("golden covenant addresses", () => { // `lending_v3` reference, and until they were vendored nothing could check that this wallet // compiles what a real protocol deployed rather than only what we wrote to suit it. describe("the simplicity-lending contracts", () => { - const CONTRACTS = "../../domain/manifest/__fixtures__/contracts"; + const CONTRACTS = "../../../../../../../../packages/tx-manifest/src/__fixtures__/contracts"; async function source(name: string): Promise<string> { const { readFile: read } = await import("node:fs/promises"); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx index cafe17c..f413b61 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { computed, fromSite, verified } from "../../../domain/manifest/provenance"; +import { computed, fromSite, verified } from "@humid/tx-manifest"; + import { isProcessCtConfirmationData, PROCESS_CT_CONFIRMATION_KIND, diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx index f1583cb..9a1ac9f 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx @@ -1,9 +1,9 @@ +import { type ConfirmationModel, describeOrigin } from "@humid/tx-manifest"; +import type { Provenanced } from "@humid/tx-manifest"; + import type { ConfirmationRenderer } from "@/common/Confirmation"; import { UiButton } from "@/ui/UiButton/base"; -import { type ConfirmationModel, describeOrigin } from "../../../domain/manifest/confirmation"; -import type { Provenanced } from "../../../domain/manifest/provenance"; - /** What the method puts on the confirmation payload, and how this surface recognises it. */ export const PROCESS_CT_CONFIRMATION_KIND = "liquid.processConfidentialTransaction"; diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index dc1acc5..43488c8 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import groupedManifest from "../../../domain/manifest/__fixtures__/p2pk-grouped.manifest.json"; -import p2pkManifest from "../../../domain/manifest/__fixtures__/p2pk.manifest.json"; +import groupedManifest from "@humid/tx-manifest/fixtures/p2pk-grouped.manifest.json"; +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; + import { createProcessLiquidConfidentialTransaction, type LiquidProcessCtContext, diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 192ebe7..633efa7 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -1,3 +1,16 @@ +import { + createEsploraFeeRateReader, + createEsploraTxOutReader, + guardSpentInputs, + isRefusal, + type ManifestReview, + type ParsedLiquidProcessCtParams, + parseLiquidProcessCtParams, + type ReadFeeRate, + type ReadTxOut, + reviewManifestAction, +} from "@humid/tx-manifest"; + import type { KeyManagerState, UpdateKeyManagerState } from "@/core/key-manager/types"; import { createWalletMethod } from "@/core/wallet-methods/createWalletMethod"; import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError } from "@/core/wallet-rpc/errors"; @@ -8,20 +21,6 @@ import { withAccountMnemonic } from "../../../adapters/lwk/wallet/withAccountMne import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; import { LIQUID_WALLET_RPC_METHODS } from "../../../domain/LiquidRpc"; -import { - createEsploraFeeRateReader, - createEsploraTxOutReader, - type ReadFeeRate, - type ReadTxOut, -} from "../../../domain/manifest/chainRead"; -import { guardSpentInputs } from "../../../domain/manifest/inputGuard"; -import { - isRefusal, - type ManifestReview, - reviewManifestAction, -} from "../../../domain/manifest/review"; -import type { ParsedLiquidProcessCtParams } from "../../../domain/manifest/types"; -import { parseLiquidProcessCtParams } from "../../../domain/manifest/validation"; import type { LiquidWalletBackend } from "../../backends/LiquidWalletBackend"; import { resolveDappAccount } from "../../dappAccountScope"; import { PROCESS_CT_CONFIRMATION_KIND } from "./ProcessCtConfirmation"; @@ -100,6 +99,26 @@ export const liquidProcessCtDependencies: LiquidProcessCtDependencies = { withMnemonic: withAccountMnemonic, }; +/** + * Turns the runtime's malformed-request answer into the wire error a caller sees. + * + * The runtime returns a value rather than throwing because it has no transport; this + * method has one, and owns how a refusal reaches whoever asked. + */ +function parseRequest(params: unknown): ParsedLiquidProcessCtParams { + const parsed = parseLiquidProcessCtParams(params); + + if (!parsed.ok) { + throw new WalletRpcInvalidParamsError( + parsed.malformed.message, + parsed.malformed.details, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return parsed.request; +} + export const createProcessLiquidConfidentialTransaction = ( dependencies: LiquidProcessCtDependencies = liquidProcessCtDependencies, ) => @@ -221,7 +240,7 @@ export const createProcessLiquidConfidentialTransaction = ( return { broadcast: true, ...signed, txid: sent.txid }; }, id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - parse: parseLiquidProcessCtParams, + parse: parseRequest, review: async ({ context, params }) => { const network = requireNetwork(context); const account = await dependencies.resolveAccount(context); diff --git a/apps/web/package.json b/apps/web/package.json index 57be9d2..c26848f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@humid/appkit-injected-adapter": "workspace:*", + "@humid/tx-manifest": "workspace:*", "@reown/appkit": "^1.8.19", "@reown/appkit-common": "^1.8.19", "@reown/appkit-controllers": "^1.8.21", diff --git a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx index 420d3e2..daf0eca 100644 --- a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx +++ b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx @@ -1,39 +1,95 @@ import type { LiquidProcessConfidentialTransactionParams } from "@humid/appkit-injected-adapter"; +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; import { useState } from "react"; import { useHumidContext } from "@/contexts/Web3Provider/HumidProvider"; +import { P2PK_SOURCE } from "../../contracts/p2pk"; import { parseJsonInput } from "../../lib/format"; import { useMethodState } from "../../lib/method-state"; import { useRpcCall } from "../../lib/useRpcCall"; import { CallButton } from "../CallButton"; -import { TextAreaField } from "../fields"; +import { CheckboxField, SelectField, TextAreaField, TextField } from "../fields"; import { ResultPanel } from "../ResultPanel"; import { RpcCard } from "../RpcCard"; +/** + * The published p2pk protocol, which is the thinnest real one: no deployment values, and a + * single kind of holding. `Pay` locks funds into it; `Receive` spends one back out, which is + * the half that exercises the address check against the network. + */ +const ACTIONS = [ + { label: "Pay — lock funds into a p2pk output", value: "Pay" }, + { label: "Receive — spend a p2pk output back to your wallet", value: "Receive" }, +]; + export function ProcessCtCard() { const { wallet } = useHumidContext(); const state = useMethodState("processConfidentialTransaction"); - const [payload, setPayload] = useState("{}"); const { call, pending, result } = useRpcCall(); + const [action, setAction] = useState("Pay"); + const [pubkey, setPubkey] = useState(""); + const [amount, setAmount] = useState("1000"); + const [broadcast, setBroadcast] = useState(false); + const [stateFile, setStateFile] = useState(""); + + const spending = action === "Receive"; + + // The six parts of the request, assembled here rather than typed by hand. The wallet + // rebuilds the contract from `contractSources` and checks it against the chain, so what + // this card supplies is exactly what a real protocol's site would supply. + const params = { + action, + broadcast, + contractSources: { "./p2pk.simf": P2PK_SOURCE }, + manifest: p2pkManifest, + params: spending ? { pubkey } : { amount_sat: Number(amount) || 0, pubkey }, + ...(spending ? { state: parseJsonInput(stateFile) ?? {} } : {}), + }; + return ( <RpcCard - description="Wallet ABI method. The extension returns a structured not_implemented error." + description="Performs one action of a txManifest protocol. The wallet rebuilds every contract from the source supplied here and refuses unless the address it derives matches where the funds actually sit." policy={state} title="processConfidentialTransaction" > - <TextAreaField label="Wallet ABI request JSON" onChange={setPayload} value={payload} /> + <SelectField label="Action" onChange={setAction} options={ACTIONS} value={action} /> + + <TextField + label="Recipient x-only public key (32 bytes, hex)" + onChange={setPubkey} + placeholder="79be667e…" + value={pubkey} + /> + + {spending ? ( + <TextAreaField + label='State file — which covenant outputs exist: {"utxos":[{"utxo_type":"p2pk_output","txid":"…","vout":0}]}' + onChange={setStateFile} + value={stateFile} + /> + ) : ( + <TextField label="Amount to lock (satoshis)" onChange={setAmount} value={amount} /> + )} + + <CheckboxField + label="Broadcast — leave off to get the signed transaction back without sending it" + onChange={setBroadcast} + value={broadcast} + /> + <CallButton disabled={pending} onClick={() => call(() => wallet.processConfidentialTransaction( - (parseJsonInput(payload) ?? {}) as LiquidProcessConfidentialTransactionParams, + params as unknown as LiquidProcessConfidentialTransactionParams, ), ) } /> + <ResultPanel result={result} /> </RpcCard> ); diff --git a/apps/web/src/app/dashboard/contracts/p2pk.ts b/apps/web/src/app/dashboard/contracts/p2pk.ts new file mode 100644 index 0000000..295ea74 --- /dev/null +++ b/apps/web/src/app/dashboard/contracts/p2pk.ts @@ -0,0 +1,13 @@ +/** + * The pay-to-public-key contract, from `simplicityhl-0.6.0/examples/p2pk.simf`. + * + * Two identifiers differ from upstream: the published manifest names its compile parameter + * `PUB_KEY` and its witness `SIGNATURE`, where upstream says `ALICE_PUBLIC_KEY` and + * `ALICE_SIGNATURE`. Nothing else about it is ours. + * + * It lives beside the page rather than beside the manifest because contract sources are not + * published with a manifest — in production they arrive with the request, which is exactly + * what this card demonstrates. + */ +export const P2PK_SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; diff --git a/bun.lock b/bun.lock index 987767a..6d96918 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@hookform/resolvers": "^5.4.0", "@hugeicons/core-free-icons": "^4.2.0", "@hugeicons/react": "^1.1.6", + "@humid/tx-manifest": "workspace:*", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", "@reactuses/core": "^6.3.3", @@ -128,6 +129,7 @@ "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@humid/appkit-injected-adapter": "workspace:*", + "@humid/tx-manifest": "workspace:*", "@reown/appkit": "^1.8.19", "@reown/appkit-common": "^1.8.19", "@reown/appkit-controllers": "^1.8.21", @@ -167,6 +169,14 @@ "@walletconnect/universal-provider": ">=2.0.0", }, }, + "packages/tx-manifest": { + "name": "@humid/tx-manifest", + "version": "1.0.0", + "dependencies": { + "@noble/hashes": "^1.7.1", + "zod": "^4.0.0", + }, + }, }, "trustedDependencies": [ "@parcel/watcher", @@ -404,6 +414,8 @@ "@humid/appkit-injected-adapter": ["@humid/appkit-injected-adapter@workspace:packages/appkit-injected-adapter"], + "@humid/tx-manifest": ["@humid/tx-manifest@workspace:packages/tx-manifest"], + "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], diff --git a/package.json b/package.json index 681ce4c..103f5da 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@hookform/resolvers": "^5.4.0", "@hugeicons/core-free-icons": "^4.2.0", "@hugeicons/react": "^1.1.6", + "@humid/tx-manifest": "workspace:*", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", "@reactuses/core": "^6.3.3", diff --git a/packages/tx-manifest/package.json b/packages/tx-manifest/package.json new file mode 100644 index 0000000..37d37bc --- /dev/null +++ b/packages/tx-manifest/package.json @@ -0,0 +1,16 @@ +{ + "name": "@humid/tx-manifest", + "version": "1.0.0", + "private": true, + "description": "Reads a txManifest protocol document and resolves one of its actions into the exact transaction a wallet must sign. Wallet-agnostic: it holds no keys, reaches no network and remembers nothing between calls.", + "type": "module", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./fixtures/*": "./src/__fixtures__/*" + }, + "dependencies": { + "@noble/hashes": "^1.7.1", + "zod": "^4.0.0" + } +} diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/README.md b/packages/tx-manifest/src/__fixtures__/README.md similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/README.md rename to packages/tx-manifest/src/__fixtures__/README.md diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/README.md b/packages/tx-manifest/src/__fixtures__/contracts/README.md similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/README.md rename to packages/tx-manifest/src/__fixtures__/contracts/README.md diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth.simf b/packages/tx-manifest/src/__fixtures__/contracts/asset_auth.simf similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth.simf rename to packages/tx-manifest/src/__fixtures__/contracts/asset_auth.simf diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth_vault.simf b/packages/tx-manifest/src/__fixtures__/contracts/asset_auth_vault.simf similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/asset_auth_vault.simf rename to packages/tx-manifest/src/__fixtures__/contracts/asset_auth_vault.simf diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/issuance_factory.simf b/packages/tx-manifest/src/__fixtures__/contracts/issuance_factory.simf similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/issuance_factory.simf rename to packages/tx-manifest/src/__fixtures__/contracts/issuance_factory.simf diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/lending.simf b/packages/tx-manifest/src/__fixtures__/contracts/lending.simf similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/lending.simf rename to packages/tx-manifest/src/__fixtures__/contracts/lending.simf diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/script_auth.simf b/packages/tx-manifest/src/__fixtures__/contracts/script_auth.simf similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/contracts/script_auth.simf rename to packages/tx-manifest/src/__fixtures__/contracts/script_auth.simf diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/dex.manifest.json b/packages/tx-manifest/src/__fixtures__/dex.manifest.json similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/dex.manifest.json rename to packages/tx-manifest/src/__fixtures__/dex.manifest.json diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/last_will.manifest.json b/packages/tx-manifest/src/__fixtures__/last_will.manifest.json similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/last_will.manifest.json rename to packages/tx-manifest/src/__fixtures__/last_will.manifest.json diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending.manifest.json b/packages/tx-manifest/src/__fixtures__/lending.manifest.json similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending.manifest.json rename to packages/tx-manifest/src/__fixtures__/lending.manifest.json diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v2.manifest.json b/packages/tx-manifest/src/__fixtures__/lending_v2.manifest.json similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v2.manifest.json rename to packages/tx-manifest/src/__fixtures__/lending_v2.manifest.json diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v3.manifest.json b/packages/tx-manifest/src/__fixtures__/lending_v3.manifest.json similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/lending_v3.manifest.json rename to packages/tx-manifest/src/__fixtures__/lending_v3.manifest.json diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk-grouped.manifest.json b/packages/tx-manifest/src/__fixtures__/p2pk-grouped.manifest.json similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk-grouped.manifest.json rename to packages/tx-manifest/src/__fixtures__/p2pk-grouped.manifest.json diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.manifest.json b/packages/tx-manifest/src/__fixtures__/p2pk.manifest.json similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.manifest.json rename to packages/tx-manifest/src/__fixtures__/p2pk.manifest.json diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.simf b/packages/tx-manifest/src/__fixtures__/p2pk.simf similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/p2pk.simf rename to packages/tx-manifest/src/__fixtures__/p2pk.simf diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/zeroconf.manifest.json b/packages/tx-manifest/src/__fixtures__/zeroconf.manifest.json similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/__fixtures__/zeroconf.manifest.json rename to packages/tx-manifest/src/__fixtures__/zeroconf.manifest.json diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts b/packages/tx-manifest/src/chainRead.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/chainRead.test.ts rename to packages/tx-manifest/src/chainRead.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts b/packages/tx-manifest/src/chainRead.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/chainRead.ts rename to packages/tx-manifest/src/chainRead.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.test.ts b/packages/tx-manifest/src/coinSelection.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.test.ts rename to packages/tx-manifest/src/coinSelection.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts b/packages/tx-manifest/src/coinSelection.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/coinSelection.ts rename to packages/tx-manifest/src/coinSelection.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts b/packages/tx-manifest/src/compileParams.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/compileParams.test.ts rename to packages/tx-manifest/src/compileParams.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts b/packages/tx-manifest/src/compileParams.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/compileParams.ts rename to packages/tx-manifest/src/compileParams.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts b/packages/tx-manifest/src/computed.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/computed.test.ts rename to packages/tx-manifest/src/computed.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/computed.ts b/packages/tx-manifest/src/computed.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/computed.ts rename to packages/tx-manifest/src/computed.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/confirmation.ts b/packages/tx-manifest/src/confirmation.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/confirmation.ts rename to packages/tx-manifest/src/confirmation.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/corpus.test.ts rename to packages/tx-manifest/src/corpus.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts b/packages/tx-manifest/src/covenant.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/covenant.test.ts rename to packages/tx-manifest/src/covenant.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts b/packages/tx-manifest/src/covenant.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/covenant.ts rename to packages/tx-manifest/src/covenant.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.test.ts b/packages/tx-manifest/src/covenantHash.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.test.ts rename to packages/tx-manifest/src/covenantHash.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.ts b/packages/tx-manifest/src/covenantHash.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/covenantHash.ts rename to packages/tx-manifest/src/covenantHash.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/encode.test.ts b/packages/tx-manifest/src/encode.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/encode.test.ts rename to packages/tx-manifest/src/encode.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/encode.ts b/packages/tx-manifest/src/encode.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/encode.ts rename to packages/tx-manifest/src/encode.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.test.ts b/packages/tx-manifest/src/evaluate.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/evaluate.test.ts rename to packages/tx-manifest/src/evaluate.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts b/packages/tx-manifest/src/evaluate.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/evaluate.ts rename to packages/tx-manifest/src/evaluate.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/fee.test.ts b/packages/tx-manifest/src/fee.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/fee.test.ts rename to packages/tx-manifest/src/fee.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/fee.ts b/packages/tx-manifest/src/fee.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/fee.ts rename to packages/tx-manifest/src/fee.ts diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts new file mode 100644 index 0000000..fa082d9 --- /dev/null +++ b/packages/tx-manifest/src/index.ts @@ -0,0 +1,32 @@ +/** + * Reads a txManifest protocol document and resolves one of its actions into the exact + * transaction a wallet must sign. + * + * What is here holds no keys, opens no network connection of its own and remembers + * nothing between calls: a wallet supplies the chain reads and the signing, and the same + * request twice produces the same plan. That is what makes it a package rather than part + * of one wallet — and it is enforced rather than intended, by `stateless.test.ts`. + */ + +export { + type ReadFeeRate, + type ReadTxOut, + createEsploraFeeRateReader, + createEsploraTxOutReader, + encodeExplicitTxOut, +} from "./chainRead"; +export { type ConfirmationModel, confirmationModel, describeOrigin } from "./confirmation"; +export { estimateFeeSats, estimateVsize } from "./fee"; +export { guardSpentInputs } from "./inputGuard"; +export { findAction, normaliseManifest } from "./normalise"; +export { type Origin, type Provenanced, computed, fromSite, verified } from "./provenance"; +export { refuseUnsupported } from "./refuse"; +export { ignored, inspectConstructs, loadBearing } from "./registry"; +export { type ManifestReview, isRefusal, reviewManifestAction } from "./review"; +export { spentInputs } from "./spentInputs"; +export type { ParsedLiquidProcessCtParams, RequestPart } from "./types"; +export { + type MalformedRequest, + type ParseRequestResult, + parseLiquidProcessCtParams, +} from "./validation"; diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.test.ts b/packages/tx-manifest/src/inputGuard.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.test.ts rename to packages/tx-manifest/src/inputGuard.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.ts b/packages/tx-manifest/src/inputGuard.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/inputGuard.ts rename to packages/tx-manifest/src/inputGuard.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/inputRules.test.ts b/packages/tx-manifest/src/inputRules.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/inputRules.test.ts rename to packages/tx-manifest/src/inputRules.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/inputRules.ts b/packages/tx-manifest/src/inputRules.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/inputRules.ts rename to packages/tx-manifest/src/inputRules.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/json.ts b/packages/tx-manifest/src/json.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/json.ts rename to packages/tx-manifest/src/json.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/normalise.test.ts b/packages/tx-manifest/src/normalise.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/normalise.test.ts rename to packages/tx-manifest/src/normalise.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/normalise.ts b/packages/tx-manifest/src/normalise.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/normalise.ts rename to packages/tx-manifest/src/normalise.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts b/packages/tx-manifest/src/plan.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/plan.test.ts rename to packages/tx-manifest/src/plan.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/plan.ts b/packages/tx-manifest/src/plan.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/plan.ts rename to packages/tx-manifest/src/plan.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/provenance.test.ts b/packages/tx-manifest/src/provenance.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/provenance.test.ts rename to packages/tx-manifest/src/provenance.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/provenance.ts b/packages/tx-manifest/src/provenance.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/provenance.ts rename to packages/tx-manifest/src/provenance.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/references.test.ts b/packages/tx-manifest/src/references.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/references.test.ts rename to packages/tx-manifest/src/references.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/references.ts b/packages/tx-manifest/src/references.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/references.ts rename to packages/tx-manifest/src/references.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/refuse.test.ts b/packages/tx-manifest/src/refuse.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/refuse.test.ts rename to packages/tx-manifest/src/refuse.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/refuse.ts b/packages/tx-manifest/src/refuse.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/refuse.ts rename to packages/tx-manifest/src/refuse.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts b/packages/tx-manifest/src/registry.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/registry.test.ts rename to packages/tx-manifest/src/registry.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/registry.ts b/packages/tx-manifest/src/registry.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/registry.ts rename to packages/tx-manifest/src/registry.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts b/packages/tx-manifest/src/requirements.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/requirements.test.ts rename to packages/tx-manifest/src/requirements.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts b/packages/tx-manifest/src/requirements.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/requirements.ts rename to packages/tx-manifest/src/requirements.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts b/packages/tx-manifest/src/review.test.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/review.test.ts rename to packages/tx-manifest/src/review.test.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/review.ts b/packages/tx-manifest/src/review.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/review.ts rename to packages/tx-manifest/src/review.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/sites.ts b/packages/tx-manifest/src/sites.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/sites.ts rename to packages/tx-manifest/src/sites.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/spentInputs.ts b/packages/tx-manifest/src/spentInputs.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/spentInputs.ts rename to packages/tx-manifest/src/spentInputs.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/stateless.test.ts b/packages/tx-manifest/src/stateless.test.ts similarity index 91% rename from apps/extension/src/core/chains/liquid/domain/manifest/stateless.test.ts rename to packages/tx-manifest/src/stateless.test.ts index e1bb53a..9ec708e 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/stateless.test.ts +++ b/packages/tx-manifest/src/stateless.test.ts @@ -54,10 +54,10 @@ describe("the runtime reads nothing it remembered", () => { } } - // One import reaches outside: the wallet RPC error the request validator throws. It - // carries no state and reads nothing, and is listed rather than excluded by pattern so - // that a second one has to be added here deliberately. - expect(offenders).toEqual(["validation.ts → @/core/wallet-rpc/errors"]); + // Nothing reaches outside the package. This was one import — the wallet RPC error the + // request validator threw — until the runtime moved out of the extension. Now a malformed + // request comes back as a value and the caller that has a transport owns the refusal. + expect(offenders).toEqual([]); }); }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/types.ts b/packages/tx-manifest/src/types.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/types.ts rename to packages/tx-manifest/src/types.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/validate.ts b/packages/tx-manifest/src/validate.ts similarity index 100% rename from apps/extension/src/core/chains/liquid/domain/manifest/validate.ts rename to packages/tx-manifest/src/validate.ts diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/validation.test.ts b/packages/tx-manifest/src/validation.test.ts similarity index 54% rename from apps/extension/src/core/chains/liquid/domain/manifest/validation.test.ts rename to packages/tx-manifest/src/validation.test.ts index 3720203..fbcaf07 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/validation.test.ts +++ b/packages/tx-manifest/src/validation.test.ts @@ -16,20 +16,34 @@ const base = { describe("parseLiquidProcessCtParams", () => { test("accepts the six-part request", () => { - expect(parseLiquidProcessCtParams(base).action).toBe("Pay"); + const parsed = parseLiquidProcessCtParams(base); + + expect(parsed.ok && parsed.request.action).toBe("Pay"); }); test("defaults broadcast to off, so nothing reaches the network unasked", () => { - expect(parseLiquidProcessCtParams(base).broadcast).toBe(false); + const parsed = parseLiquidProcessCtParams(base); + + expect(parsed.ok && parsed.request.broadcast).toBe(false); }); for (const supplied of ["fee", "feeSats", "feeRate", "feeRateSatsPerKvb"]) { test(`refuses a request carrying ${supplied}`, () => { - expect(() => parseLiquidProcessCtParams({ ...base, [supplied]: 500 })).toThrow(); + expect(parseLiquidProcessCtParams({ ...base, [supplied]: 500 }).ok).toBe(false); }); } test("refuses a request with no manifest", () => { - expect(() => parseLiquidProcessCtParams({ ...base, manifest: undefined })).toThrow(); + expect(parseLiquidProcessCtParams({ ...base, manifest: undefined }).ok).toBe(false); + }); + + // The caller needs to know which field was wrong, not only that something was. It is a + // value rather than a thrown transport error because this package has no transport. + test("and says which field, so the caller can name it", () => { + const parsed = parseLiquidProcessCtParams({ ...base, manifest: undefined }); + + expect(parsed.ok ? [] : Object.keys(parsed.malformed.details.fieldErrors)).toContain( + "manifest", + ); }); }); diff --git a/apps/extension/src/core/chains/liquid/domain/manifest/validation.ts b/packages/tx-manifest/src/validation.ts similarity index 53% rename from apps/extension/src/core/chains/liquid/domain/manifest/validation.ts rename to packages/tx-manifest/src/validation.ts index 9e378fd..0e63347 100644 --- a/apps/extension/src/core/chains/liquid/domain/manifest/validation.ts +++ b/packages/tx-manifest/src/validation.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError } from "@/core/wallet-rpc/errors"; - import type { ParsedLiquidProcessCtParams } from "./types"; const jsonObjectSchema = z.record(z.string(), z.unknown()); @@ -18,21 +16,35 @@ const processCtParamsSchema = z }) .strict(); +/** A malformed request, with the field-by-field detail a caller can show or wrap. */ +export type MalformedRequest = { + details: z.core.$ZodFlattenedError<Record<string, unknown>>; + message: string; +}; + +export type ParseRequestResult = + | { ok: false; malformed: MalformedRequest } + | { ok: true; request: ParsedLiquidProcessCtParams }; + /** * Checks the request is well-formed. Whether the chosen action can actually be built * from it is a separate question — see `resolveActionRequirements`, which reads the * manifest rather than the request's shape. + * + * A malformed request comes back as a value rather than a thrown transport error: this + * package has no transport, and the caller that does owns how a refusal reaches whoever + * asked. */ -export function parseLiquidProcessCtParams(value: unknown): ParsedLiquidProcessCtParams { +export function parseLiquidProcessCtParams(value: unknown): ParseRequestResult { const parsed = processCtParamsSchema.safeParse(value); - if (!parsed.success) { - throw new WalletRpcInvalidParamsError( - "Invalid processConfidentialTransaction parameters.", - parsed.error.flatten(), - WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, - ); - } - - return parsed.data; + return parsed.success + ? { ok: true, request: parsed.data } + : { + malformed: { + details: z.flattenError(parsed.error), + message: "Invalid processConfidentialTransaction parameters.", + }, + ok: false, + }; } From 581128ed0ec123357e8ac0c7ff2cafec2ffe30e3 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:46:59 +0300 Subject: [PATCH 057/124] feat(liquid): show the address and key a contract action is actually signed with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A contract action is signed by one key at m/84h/{1|1776}h/0h/0/0, and the module funds from that key's own unblinded address and returns change there. So an action can only spend what sits at that address, and only what sits there unblinded — while the wallet's own screens show lwk's confidential addresses across a ranged descriptor. Neither the address nor the key was reachable from anywhere, which made a live run impossible to aim: funding went to an address the signer does not hold, and locking a covenant to "this wallet" required a key nobody could read. Settings → the account → Contract signing identity now reads both from the background on demand, with what each is for. It loads the contract module, so it is read on a click rather than with the page. This is narrower than the real fix — the module signing each input at its own path and taking a change address from the wallet (DISC-053) — and deliberately so: it makes the one-address limit visible instead of hiding it behind a transaction that fails late. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- apps/extension/src/background.ts | 19 ++++ .../application/contractIdentity.test.ts | 82 ++++++++++++++++ .../liquid/application/contractIdentity.ts | 77 +++++++++++++++ .../chains/liquid/contractIdentityClient.ts | 14 +++ .../internal-rpc/index.ts | 5 + .../internal-rpc/liquid-contract.ts | 22 +++++ .../Account/components/AccountDetailView.tsx | 3 + .../components/ContractIdentityRow.tsx | 98 +++++++++++++++++++ .../components/method-cards/ProcessCtCard.tsx | 5 +- 9 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts create mode 100644 apps/extension/src/core/chains/liquid/application/contractIdentity.ts create mode 100644 apps/extension/src/core/chains/liquid/contractIdentityClient.ts create mode 100644 apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts create mode 100644 apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 9a07aa3..8ca0451 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -16,6 +16,10 @@ import type { import type { Caip25Scopes } from "@/core/caip25"; import { addUnlockedChainRecord } from "@/core/chains/application/chain-store/addChainRecord"; import { getUnlockedChainStoreState } from "@/core/chains/application/chain-store/secureChainStore"; +import { + type LiquidContractIdentity, + readLiquidContractIdentity, +} from "@/core/chains/liquid/application/contractIdentity"; import { buildLiquidDappAccountScope, resolveAccountGroupIdsForIdentifiers, @@ -275,6 +279,20 @@ const init = async () => { const getReceiveAddress = async (): Promise<ReceiveAddress> => liquidChainGroup.accountRuntime.getReceiveAddress((await resolveSelectedLiquidAccount()).input); + // The address and key contract actions are signed with, for the selected account. Not + // the same as the receive address above: the contract SDK signs with one key at a fixed + // path and returns change to that key's own unblinded address, so a covenant action can + // only spend what sits there. Reading it is what makes that limit visible. + const readContractIdentity = async (): Promise<LiquidContractIdentity> => { + const { input } = await resolveSelectedLiquidAccount(); + + return readLiquidContractIdentity({ + accountGroupIndex: input.accountGroupIndex, + chain: input.chain, + keyManagerState: input.keyManagerState, + }); + }; + // In-extension send: preview then execute against the SELECTED account (resolved exactly like // getReceiveAddress). Both call the chain group's runtime, which calls the same backend fns the // dapp path uses — but WITHOUT the dapp confirmation popup, because the popup's own review screen @@ -580,6 +598,7 @@ const init = async () => { getActivity, getPortfolio, getReceiveAddress, + readContractIdentity, inspectTransfer, purgeAccountPortfolio, purgeAccountWalletConnectSessions, diff --git a/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts new file mode 100644 index 0000000..45f03b5 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; + +import type { LiquidChainRecord } from "../chains/LiquidChainRecord"; +import { readLiquidContractIdentity } from "./contractIdentity"; + +// The two values a person needs before a contract action can be aimed anywhere: the +// address the contract SDK signs from, and the x-only key a covenant locking to this +// wallet is parameterised with. Neither was reachable before, which is why a live run +// could not be composed at all (DISC-132). + +const ADDRESS = "ert1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"; +const KEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +function chain(network: string): LiquidChainRecord { + return { settings: { network } } as unknown as LiquidChainRecord; +} + +function deps(freed: string[] = []) { + return { + loadSmplx: async () => ({ + WalletSigner: class { + constructor( + readonly mnemonic: string, + readonly network: string, + ) {} + address() { + return `${ADDRESS}:${this.network}`; + } + free() { + freed.push(this.mnemonic); + } + schnorrPublicKey() { + return KEY; + } + }, + }), + withMnemonic: async (_request: unknown, use: (mnemonic: string) => unknown): Promise<unknown> => + use("about about about"), + } as never; +} + +describe("the contract signing identity", () => { + test("is the SDK signer's own address and key, not the wallet's", async () => { + const identity = await readLiquidContractIdentity( + { accountGroupIndex: 0, chain: chain("testnet"), keyManagerState: {} as never }, + deps(), + ); + + expect(identity).toEqual({ address: `${ADDRESS}:liquid-testnet`, schnorrPublicKey: KEY }); + }); + + test("is read on the chain's own network, so a regtest run gets regtest answers", async () => { + const identity = await readLiquidContractIdentity( + { accountGroupIndex: 0, chain: chain("regtest"), keyManagerState: {} as never }, + deps(), + ); + + expect(identity.address).toBe(`${ADDRESS}:elements-regtest`); + }); + + // The signer holds key material across the wasm boundary. Leaving one alive after the + // read would keep it there for as long as the worker lives. + test("releases the signer once the two values are out", async () => { + const freed: string[] = []; + + await readLiquidContractIdentity( + { accountGroupIndex: 0, chain: chain("mainnet"), keyManagerState: {} as never }, + deps(freed), + ); + + expect(freed).toEqual(["about about about"]); + }); + + test("refuses a network the SDK does not know rather than guessing one", async () => { + const read = readLiquidContractIdentity( + { accountGroupIndex: 0, chain: chain("signet"), keyManagerState: {} as never }, + deps(), + ); + + await expect(read).rejects.toThrow("signet"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/contractIdentity.ts b/apps/extension/src/core/chains/liquid/application/contractIdentity.ts new file mode 100644 index 0000000..d244f8f --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.ts @@ -0,0 +1,77 @@ +import type { KeyManagerState } from "@/core/key-manager/types"; + +import { withAccountMnemonic } from "../adapters/lwk/wallet/withAccountMnemonic"; +import { loadSmplxWasm } from "../adapters/smplx/loadSmplxWasm"; +import type { LiquidChainRecord } from "../chains/LiquidChainRecord"; + +/** The network names the SDK understands, keyed by the wallet's own network kind. */ +const SMPLX_NETWORKS: Record<string, string> = { + mainnet: "liquid", + regtest: "elements-regtest", + testnet: "liquid-testnet", +}; + +/** + * The one identity a contract action is signed with. + * + * This is not the wallet's own address and is not interchangeable with it. Contract + * actions are signed inside the smplx module by a single key at + * `m/84h/{1|1776}h/0h/0/0`, and the module funds and returns change to that key's own + * unblinded address rather than to a wallet change address — so a covenant action can + * only spend what sits there, and only what sits there unblinded. + * + * Both values are read-only and public: an address anyone can pay, and the x-only form + * of the same key. Nothing here derives, stores or returns a secret. + */ +export type LiquidContractIdentity = { + /** The unblinded address contract actions can be funded from, and where change returns. */ + address: string; + /** The x-only public key a covenant locking to "the wallet's key" is parameterised with. */ + schnorrPublicKey: string; +}; + +export type ReadLiquidContractIdentityInput = { + accountGroupIndex: number; + chain: LiquidChainRecord; + keyManagerState: KeyManagerState; +}; + +/** + * Reads the address and key that contract actions are signed with. + * + * It exists because neither value was reachable from anywhere: the wallet's own screens + * show lwk's confidential addresses across a ranged descriptor, and no method returned + * the signing key — so funding a contract action meant guessing an address, and locking + * a covenant to this wallet meant guessing a key. Both guesses fail late, one of them + * by making funds unspendable. + * + * Showing them is a narrower answer than the one this eventually needs, which is for the + * module to sign each input at its own derivation path and take a change address from + * the wallet (DISC-053). Until that lands, the limit is real and this makes it visible + * rather than hidden. + */ +export async function readLiquidContractIdentity( + { accountGroupIndex, chain, keyManagerState }: ReadLiquidContractIdentityInput, + dependencies = { loadSmplx: loadSmplxWasm, withMnemonic: withAccountMnemonic }, +): Promise<LiquidContractIdentity> { + const network = SMPLX_NETWORKS[chain.settings.network]; + + if (!network) { + throw new Error(`The contract SDK does not support the ${chain.settings.network} network.`); + } + + const smplx = await dependencies.loadSmplx(); + + return dependencies.withMnemonic( + { accountGroupIndex, chain, keyManagerState }, + (mnemonic: string) => { + const signer = new smplx.WalletSigner(mnemonic, network); + + try { + return { address: signer.address(), schnorrPublicKey: signer.schnorrPublicKey() }; + } finally { + signer.free(); + } + }, + ); +} diff --git a/apps/extension/src/core/chains/liquid/contractIdentityClient.ts b/apps/extension/src/core/chains/liquid/contractIdentityClient.ts new file mode 100644 index 0000000..c7c1287 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/contractIdentityClient.ts @@ -0,0 +1,14 @@ +import { liquidContractRpc } from "@/core/extension-background/internal-rpc/liquid-contract"; +import { requestBackground } from "@/core/extension-rpc"; + +import type { LiquidContractIdentity } from "./application/contractIdentity"; + +/** + * Reads the address and key contract actions are signed with, for the selected account. + * + * Popup-side only. The background holds the contract module and the key material; this + * asks it for the two public values and nothing else. + */ +export function readLiquidContractIdentity(): Promise<LiquidContractIdentity> { + return requestBackground<LiquidContractIdentity>(liquidContractRpc.methods.identity); +} diff --git a/apps/extension/src/core/extension-background/internal-rpc/index.ts b/apps/extension/src/core/extension-background/internal-rpc/index.ts index 2c2451a..6a8a53c 100644 --- a/apps/extension/src/core/extension-background/internal-rpc/index.ts +++ b/apps/extension/src/core/extension-background/internal-rpc/index.ts @@ -10,12 +10,14 @@ import type { TransferReview, } from "@/core/accounts/application/accounts-rpc/model/types"; import type { ChainGroup } from "@/core/chains/application/ChainGroup"; +import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; import type { ConfirmationRequest } from "@/helpers/background"; import type { ConfirmationResponder } from "../confirmations"; import type { RequestHandlerMap } from "../transport"; import { createAccountsInternalHandlers } from "./accounts"; import { createChainsInternalHandlers } from "./chains"; +import { createLiquidContractInternalHandlers } from "./liquid-contract"; import { walletVaultInternalHandlers } from "./wallet-vault"; import { walletConnectInternalHandlers } from "./walletconnect"; @@ -30,6 +32,7 @@ export type CreateInternalRpcHandlersInput = { getReceiveAddress: () => Promise<ReceiveAddress>; inspectTransfer: (input: SendTransferInput) => Promise<TransferReview>; purgeAccountPortfolio: (accountGroupId: string) => Promise<void>; + readContractIdentity: () => Promise<LiquidContractIdentity>; purgeAccountWalletConnectSessions: (accountGroupIds: readonly string[]) => Promise<void>; refreshPortfolio: () => Promise<PortfolioSnapshot>; sendTransfer: (input: SendTransferInput) => Promise<SendTransferResult>; @@ -49,6 +52,7 @@ export function createInternalRpcHandlers({ inspectTransfer, purgeAccountPortfolio, purgeAccountWalletConnectSessions, + readContractIdentity, refreshPortfolio, sendTransfer, }: CreateInternalRpcHandlersInput): RequestHandlerMap { @@ -73,6 +77,7 @@ export function createInternalRpcHandlers({ ...walletVaultInternalHandlers, ...walletConnectInternalHandlers, ...createChainsInternalHandlers(chainGroups), + ...createLiquidContractInternalHandlers(readContractIdentity), ...createAccountsInternalHandlers({ estimateMaxSend, getActivity, diff --git a/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts b/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts new file mode 100644 index 0000000..36fa307 --- /dev/null +++ b/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts @@ -0,0 +1,22 @@ +import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; + +import type { RequestHandlerMap } from "../transport"; + +export const liquidContractRpc = { + methods: { + identity: "liquid.contractIdentity", + }, +} as const; + +/** + * Reads the address and key that contract actions are signed with, for the selected + * account. Popup-only: the transport dispatches injected senders to a separate + * registry, so a dapp cannot reach this. + */ +export function createLiquidContractInternalHandlers( + readContractIdentity: () => Promise<LiquidContractIdentity>, +): RequestHandlerMap { + return { + [liquidContractRpc.methods.identity]: () => readContractIdentity(), + }; +} diff --git a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx index 1585c8b..5c7ff1b 100644 --- a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx +++ b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx @@ -31,6 +31,8 @@ import { } from "@/ui/UiDialog"; import { UiScrollArea } from "@/ui/UiScrollArea"; +import { ContractIdentityRow } from "./ContractIdentityRow"; + type AccountDetailViewProps = { accountGroupId: AccountGroupId; accountName: string; @@ -111,6 +113,7 @@ export function AccountDetailView({ > <SettingsRowContent icon={PlugSocketIcon} label="Connected dapps" /> </Link> + <ContractIdentityRow /> <button className={cn(settingsRowClass, "text-destructive hover:bg-destructive/10")} onClick={() => setRemoveOpen(true)} diff --git a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx new file mode 100644 index 0000000..e87928c --- /dev/null +++ b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx @@ -0,0 +1,98 @@ +import { CodeIcon } from "@hugeicons/core-free-icons"; +import { useState } from "react"; + +import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; +import { readLiquidContractIdentity } from "@/core/chains/liquid/contractIdentityClient"; +import { + settingsRowClass, + SettingsRowContent, +} from "@/routes/App/pages/Settings/components/SettingsRow"; +import { cn } from "@/theme/utils.ts"; +import { UiCopyButton } from "@/ui/UiCopyButton"; +import { UiSpinner } from "@/ui/UiSpinner"; + +/** One value, with what it is for and a way to take it out. */ +function Value({ hint, label, value }: { hint: string; label: string; value: string }) { + return ( + <div className="flex flex-col gap-1 px-3 py-2"> + <div className="flex items-center justify-between gap-2"> + <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> + {label} + </span> + <UiCopyButton value={value} /> + </div> + <span className="font-mono text-xs break-all">{value}</span> + <span className="text-muted-foreground text-xs">{hint}</span> + </div> + ); +} + +/** + * The address and key that contract actions are signed with. + * + * It is a separate row from the wallet's own address on purpose. Contract actions are + * signed by a single key inside the contract SDK, which funds from that key's own + * unblinded address and returns change to it — so paying a contract action from any + * other wallet address produces a transaction that cannot be signed. Until the SDK signs + * each input at its own path, saying so is more use than hiding it. + * + * Nothing here is secret: an address anyone can pay and the public form of one key. It + * is read on demand rather than with the page because reading it loads the contract + * module, which is several megabytes. + */ +export function ContractIdentityRow() { + const [identity, setIdentity] = useState<LiquidContractIdentity>(); + const [error, setError] = useState<string>(); + const [loading, setLoading] = useState(false); + + const read = async () => { + setLoading(true); + setError(undefined); + + try { + setIdentity(await readLiquidContractIdentity()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not read the contract identity."); + } finally { + setLoading(false); + } + }; + + return ( + <> + <button + className={cn(settingsRowClass, "hover:bg-accent")} + disabled={loading} + onClick={() => void read()} + type="button" + > + <SettingsRowContent + icon={CodeIcon} + label="Contract signing identity" + trailing={loading ? <UiSpinner /> : undefined} + /> + </button> + + {error === undefined ? null : <p className="text-destructive px-3 py-2 text-xs">{error}</p>} + + {identity === undefined ? null : ( + <div className="bg-muted/40 mx-3 my-1 rounded-lg py-1"> + <Value + hint="Fund contract actions from here, unblinded. Change returns to this address." + label="Contract address" + value={identity.address} + /> + <Value + hint="Lock a covenant to this wallet with this key — a protocol parameter naming the signer takes it." + label="Contract public key (x-only)" + value={identity.schnorrPublicKey} + /> + <p className="text-muted-foreground px-3 pt-1 pb-2 text-xs"> + One key signs every contract action, so only what sits at this address can be spent by + one. This is narrower than the wallet's own balance. + </p> + </div> + )} + </> + ); +} diff --git a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx index daf0eca..2c4b829 100644 --- a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx +++ b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx @@ -56,8 +56,11 @@ export function ProcessCtCard() { > <SelectField label="Action" onChange={setAction} options={ACTIONS} value={action} /> + {/* One key signs every contract action, and it is not one the wallet's normal + screens show. To spend what Pay locks, this must be the wallet's own contract + key — HUMID → Settings → the account → Contract signing identity. */} <TextField - label="Recipient x-only public key (32 bytes, hex)" + label="Recipient x-only public key — for a spendable output, the wallet's own contract key" onChange={setPubkey} placeholder="79be667e…" value={pubkey} From 3401c5d47017a58f431a503f0722e32e27e56c06 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 17:57:51 +0300 Subject: [PATCH 058/124] chore(workflow): take the shipped stop-guard fix and drop the local one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard kept its state at `.workflow/current/stop-guard.json`, where wfctl reads every `*.json` as a work binding — which broke every `wfctl work` command until a local one-line patch moved it to `.workflow/runtime/`. Upstream has since fixed it, and to a better place: `.workflow/current/hooks/`, one level below the binding scan and still gitignored. `.workflow/runtime/` was the wrong home, because upgrades own it and Git tracks it. So the local patch is now the only thing holding a conflict open. The file is byte-identical to the shipped template again, and `wfctl upgrade --dry-run` reports 0 conflicts where it reported 1 on every run since 2026-08-03. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .gitignore | 7 ++++--- .workflow/runtime/guard-stop.mjs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 57c80b1..583ab9f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ graphify-out/ # wfctl:end # The stop guard's own state, which is per-session runtime bookkeeping rather than -# configuration. It lives beside the hook because `.workflow/current/` is where wfctl -# looks for work bindings and it parsed this as a malformed one. -.workflow/runtime/stop-guard.json +# configuration. It sits one level below `.workflow/current/` because wfctl reads every +# `*.json` at the top of that directory as a work binding and parsed this as a malformed +# one; a subdirectory is invisible to that scan. +.workflow/current/hooks/ diff --git a/.workflow/runtime/guard-stop.mjs b/.workflow/runtime/guard-stop.mjs index 2d17e73..a04eab3 100644 --- a/.workflow/runtime/guard-stop.mjs +++ b/.workflow/runtime/guard-stop.mjs @@ -157,8 +157,19 @@ function stateFingerprint(report) { .digest("hex"); } +/** + * Under `.workflow/current/`, which is gitignored, but one level down: in a leaf + * repository wfctl reads every `*.json` at the top of that directory as an + * active-work binding, so a state file left there broke every `wfctl work` + * command with "Unsupported or malformed active work binding". A subdirectory + * is invisible to that scan. + * + * `.workflow/runtime/` looks like the obvious home and is the wrong one: it + * holds installed assets that upgrades own and Git tracks, so mutable state + * there both dirties the tree and turns every upgrade into a conflict. + */ function memoryPath(cwd) { - return join(cwd, ".workflow/runtime/stop-guard.json"); + return join(cwd, ".workflow/current/hooks/stop-guard.json"); } function readMemory(cwd) { From 1ad57c9ed0b513a8c4e77962c8cbdf620879b522 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 18:02:34 +0300 Subject: [PATCH 059/124] fix(check): typecheck the two thirds of the repository that were never checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card was shipped passing `{label, value}` objects to a field whose props say `string[]`, and a checkbox given `value` where it takes `checked`. `bun run check` passed on all of it, and the errors only appeared as a React crash in the browser. Two holes, neither of them in the code that broke: `apps/web`'s typecheck script was `tsc --noEmit` against a config with `files: []` and project references. That combination checks nothing at all and exits 0 — only `tsc -b` follows references. Every type error in the web app since that config was written has been invisible. The root `check` typechecked `apps/extension/src` and nothing else. `packages/` was outside every project, so the runtime that just moved there stopped being typechecked by the act of moving. Both are closed: `apps/web` uses `tsc -b`, `packages/` gets a project of its own, and `check` runs all three. Verified by reintroducing the exact bug — the gate now fails on it and passes without it. `packages/bun-test-env.d.ts` is new for the same reason the extension has one: `bun:test` does not resolve without the reference. Nothing needed it while nothing checked those files. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- apps/web/package.json | 2 +- .../components/method-cards/ProcessCtCard.tsx | 18 ++++++++---- package.json | 29 ++++++++++--------- packages/bun-test-env.d.ts | 10 +++++++ packages/tsconfig.json | 8 +++++ 5 files changed, 46 insertions(+), 21 deletions(-) create mode 100644 packages/bun-test-env.d.ts create mode 100644 packages/tsconfig.json diff --git a/apps/web/package.json b/apps/web/package.json index c26848f..4700a77 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b --force", "preview": "vite preview", "cleanup": "rm -rf node_modules out dist" }, diff --git a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx index 2c4b829..bd6baba 100644 --- a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx +++ b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx @@ -18,10 +18,7 @@ import { RpcCard } from "../RpcCard"; * single kind of holding. `Pay` locks funds into it; `Receive` spends one back out, which is * the half that exercises the address check against the network. */ -const ACTIONS = [ - { label: "Pay — lock funds into a p2pk output", value: "Pay" }, - { label: "Receive — spend a p2pk output back to your wallet", value: "Receive" }, -]; +const ACTIONS = ["Pay", "Receive"]; export function ProcessCtCard() { const { wallet } = useHumidContext(); @@ -54,7 +51,16 @@ export function ProcessCtCard() { policy={state} title="processConfidentialTransaction" > - <SelectField label="Action" onChange={setAction} options={ACTIONS} value={action} /> + <SelectField + label={ + spending + ? "Action — Receive spends a p2pk output back to your wallet" + : "Action — Pay locks funds into a p2pk output" + } + onValueChange={setAction} + options={ACTIONS} + value={action} + /> {/* One key signs every contract action, and it is not one the wallet's normal screens show. To spend what Pay locks, this must be the wallet's own contract @@ -77,9 +83,9 @@ export function ProcessCtCard() { )} <CheckboxField + checked={broadcast} label="Broadcast — leave off to get the signed transaction back without sending it" onChange={setBroadcast} - value={broadcast} /> <CallButton diff --git a/package.json b/package.json index 103f5da..7e94635 100644 --- a/package.json +++ b/package.json @@ -8,28 +8,29 @@ ], "type": "module", "scripts": { - "dev": "vite", - "start": "vite", + "analyze": "vite build --mode analyze", "build": "tsc && vite build", + "build-storybook": "storybook build", "build:wasm": "smplx/crates/wasm/build.sh", "build:watch": "vite build --watch --mode development", - "analyze": "vite build --mode analyze", - "preview": "vite preview", - "web:dev": "bun --filter='./apps/web' run dev", - "web:build": "bun --filter='./apps/web' run build", - "web:preview": "bun --filter='./apps/web' run preview", - "web:typecheck": "bun --filter='./apps/web' run typecheck", - "typecheck": "tsc --noEmit", - "lint": "oxlint .", - "lint:fix": "oxlint . --fix", + "check": "bun run typecheck && bun run typecheck:packages && bun run web:typecheck && bun run lint && bun run format:check && bun run test", + "dev": "vite", "format": "oxfmt . --write", "format:check": "oxfmt . --check", + "lint": "oxlint .", + "lint:fix": "oxlint . --fix", + "preview": "vite preview", + "start": "vite", + "storybook": "storybook dev -p 6006", "test": "bun test", "test:watch": "bun test --watch", + "typecheck": "tsc --noEmit", + "typecheck:packages": "tsc -p packages/tsconfig.json --noEmit", "validate:i18n": "i18n-check -l apps/extension/src/localization/resources -s en -f i18next", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build", - "check": "bun run typecheck && bun run lint && bun run format:check && bun run test" + "web:build": "bun --filter='./apps/web' run build", + "web:dev": "bun --filter='./apps/web' run dev", + "web:preview": "bun --filter='./apps/web' run preview", + "web:typecheck": "bun --filter='./apps/web' run typecheck" }, "dependencies": { "@base-ui/react": "^1.5.0", diff --git a/packages/bun-test-env.d.ts b/packages/bun-test-env.d.ts new file mode 100644 index 0000000..e3bbb93 --- /dev/null +++ b/packages/bun-test-env.d.ts @@ -0,0 +1,10 @@ +/// <reference types="bun-types" /> + +// Makes `bun:test` resolvable to `tsc`, which the test files import from. Same +// arrangement, and the same reason, as `apps/extension/src/bun-test-env.d.ts`: +// `@types/bun` re-exports `bun-types` and is supposed to be picked up automatically, +// but is not under this project's configuration. +// +// It exists because until the packages were typechecked at all, nothing needed it. + +export {}; diff --git a/packages/tsconfig.json b/packages/tsconfig.json new file mode 100644 index 0000000..32ac2ce --- /dev/null +++ b/packages/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "../node_modules/.cache/tsbuildinfo.packages.json", + "paths": {} + }, + "include": ["."] +} From 2a8031848310e397cf1a7b77c53097f5a04feb90 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 18:02:53 +0300 Subject: [PATCH 060/124] fix(hooks): let the commit hook see the same three projects the gate does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook ran the root typecheck only, which covers apps/extension/src. A type error in apps/web or packages/ committed cleanly and would only have surfaced in CI, or — as it did — in the browser. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- lefthook.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lefthook.yml b/lefthook.yml index 03827a0..2f54192 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,8 +4,15 @@ pre-commit: commands: lint: run: bun run lint + # Three projects, not one. The root config covers only `apps/extension/src`, and + # `apps/web`'s own config is a solution file — `tsc --noEmit` against it checks + # nothing and exits 0, so only `tsc -b` follows its references. typecheck: run: bun run typecheck + typecheck-packages: + run: bun run typecheck:packages + typecheck-web: + run: bun run web:typecheck validate-i18n: run: bun run validate:i18n format: From 8f9d53e5133961d3d2a150f9121709c5e6e6bb53 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 18:07:49 +0300 Subject: [PATCH 061/124] fix(manifest): refuse a value that cannot be its declared type, before the compiler sees it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A confidential address pasted into the field that wants an x-only key was hex-prefixed and handed to the contract compiler, which failed inside its own parser: "Expected 'end of input', found 'xtlq1qqd54s2…' at line 1 column 143". That error is true and about the wrong thing. The fault is in the request, and nothing in the message says which compile parameter, which reference, or what shape was wanted — a person has to work backwards from a character position to find out they pasted the wrong one of the two values the wallet showed them. A type with a fixed width is now checked against it first, and the refusal names the parameter, the reference, what arrived and what an x-only key is. The card checks the same thing before it sends, because the mistake it catches is one glance away: the contract identity screen shows an address and a key together, and only one of them belongs here. Also adds the CI gate this repository never had. The only workflows were manual builds and deploys, so no test has ever run in CI — 455 of them protecting nothing that could block a merge. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .github/workflows/check.yml | 35 +++++++++++ .../components/method-cards/ProcessCtCard.tsx | 30 +++++++++- .../tx-manifest/src/compileParams.test.ts | 41 +++++++++++++ packages/tx-manifest/src/compileParams.ts | 58 +++++++++++++++++++ 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..05a9f20 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,35 @@ +name: Check + +# Runs the same gate a commit runs locally, on every push and pull request. Until this +# existed nothing in CI ran the tests at all: the only workflows were manual builds and +# deploys, so 451 tests protected nothing that could block a merge. +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + # Not recursive. The wasm submodules take minutes to build and nothing here + # imports them: the tests that touch the SDK load a prebuilt package and skip + # themselves when it is absent, and typechecking reads its .d.ts from the + # workspace link. A gate that needs a Rust toolchain is a gate people turn off. + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Install dependencies + uses: ./.github/actions/install + + # typecheck across apps/extension, packages/ and apps/web, then lint, format and + # the test suite. The three projects are separate deliberately — see the comment + # in lefthook.yml for why one `tsc --noEmit` never covered them. + - name: Check + run: bun run check diff --git a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx index bd6baba..2a6f9c8 100644 --- a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx +++ b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx @@ -20,6 +20,16 @@ import { RpcCard } from "../RpcCard"; */ const ACTIONS = ["Pay", "Receive"]; +/** + * An x-only public key, which is what the p2pk contract's PUB_KEY parameter is. + * + * Checked here rather than left to the wallet because the mistake this catches is the + * obvious one — pasting an address, which is the other thing the wallet shows you — and + * a request that leaves this page is answered by the contract compiler complaining about + * a character position. + */ +const X_ONLY_KEY = /^(?:0x)?[0-9a-fA-F]{64}$/; + export function ProcessCtCard() { const { wallet } = useHumidContext(); const state = useMethodState("processConfidentialTransaction"); @@ -32,6 +42,16 @@ export function ProcessCtCard() { const [stateFile, setStateFile] = useState(""); const spending = action === "Receive"; + const keyProblem = X_ONLY_KEY.test(pubkey.trim()) + ? undefined + : pubkey.trim() === "" + ? "Needed: 32 bytes as 64 hexadecimal characters." + : pubkey.trim().startsWith("tlq1") || + pubkey.trim().startsWith("tex1") || + pubkey.trim().startsWith("lq1") || + pubkey.trim().startsWith("ex1") + ? "That is an address, not a key. The contract identity screen shows both — this field wants the second one." + : `Not an x-only public key: ${pubkey.trim().length} characters, and 64 hexadecimal ones are needed.`; // The six parts of the request, assembled here rather than typed by hand. The wallet // rebuilds the contract from `contractSources` and checks it against the chain, so what @@ -41,7 +61,9 @@ export function ProcessCtCard() { broadcast, contractSources: { "./p2pk.simf": P2PK_SOURCE }, manifest: p2pkManifest, - params: spending ? { pubkey } : { amount_sat: Number(amount) || 0, pubkey }, + params: spending + ? { pubkey: pubkey.trim() } + : { amount_sat: Number(amount) || 0, pubkey: pubkey.trim() }, ...(spending ? { state: parseJsonInput(stateFile) ?? {} } : {}), }; @@ -72,6 +94,10 @@ export function ProcessCtCard() { value={pubkey} /> + {keyProblem === undefined ? null : ( + <p className="text-xs text-amber-600 dark:text-amber-500">{keyProblem}</p> + )} + {spending ? ( <TextAreaField label='State file — which covenant outputs exist: {"utxos":[{"utxo_type":"p2pk_output","txid":"…","vout":0}]}' @@ -89,7 +115,7 @@ export function ProcessCtCard() { /> <CallButton - disabled={pending} + disabled={pending || keyProblem !== undefined} onClick={() => call(() => wallet.processConfidentialTransaction( diff --git a/packages/tx-manifest/src/compileParams.test.ts b/packages/tx-manifest/src/compileParams.test.ts index a8ec8d2..f312a31 100644 --- a/packages/tx-manifest/src/compileParams.test.ts +++ b/packages/tx-manifest/src/compileParams.test.ts @@ -139,3 +139,44 @@ describe("boolean compile parameters", () => { ).toMatchObject({ arguments: { B: { value: "maybe" } } }); }); }); + +// What a person actually does wrong: paste the address the wallet showed them into the +// field that wants a key. Without a width check the value is hex-prefixed and handed to +// the compiler, which fails inside its own parser at a character position — a true error +// about the wrong thing (DISC-134). +describe("a value that cannot be its declared type", () => { + const wiring = { PUB_KEY: "params.pubkey" }; + const types = { pubkey: "pubkey" }; + + function resolve(pubkey: string) { + return resolveCompileParams(wiring, types, scope({ pubkey })); + } + + test("a confidential address where a key belongs is refused, saying what was wanted", () => { + const result = resolve( + "tlq1qqd54s2q2d7fqv2nv8y6pnfh2w0sjr2tvu43tpsjvlm8fshffwrfy8lc2n9t96aqxtz5zv9mdhlp3hzklkfppg852dg7urtnyu", + ); + + expect(result.ok ? "" : result.reason).toContain("x-only public key"); + }); + + test("and names the compile parameter and the reference, which the compiler's own error cannot", () => { + const result = resolve("nonsense"); + + expect(result.ok ? "" : result.reason).toContain("PUB_KEY"); + expect(result.ok ? "" : result.reason).toContain("params.pubkey"); + }); + + test("a key of the wrong length is refused by length, not by looking wrong", () => { + const result = resolve("79be667e"); + + expect(result.ok ? "" : result.reason).toContain("8 hexadecimal characters"); + }); + + test("a real x-only key passes, with or without the prefix", () => { + const key = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + expect(resolve(key).ok).toBe(true); + expect(resolve(`0x${key}`).ok).toBe(true); + }); +}); diff --git a/packages/tx-manifest/src/compileParams.ts b/packages/tx-manifest/src/compileParams.ts index ed19470..d3193a3 100644 --- a/packages/tx-manifest/src/compileParams.ts +++ b/packages/tx-manifest/src/compileParams.ts @@ -35,6 +35,27 @@ const PARAM_TYPES: Record<string, string> = { pubkey: "Pubkey", }; +/** + * How many bytes a declared type occupies, for the types that have a fixed width. + * + * A value of the wrong width does not fail here without this — it is hex-prefixed and + * handed to the compiler, which rejects it somewhere inside its own parser with a + * message about the parse position. That is a true error about the wrong thing: the + * fault is in the request, not the contract, and a person reading "expected end of + * input at line 1 column 143" has to work backwards to find out that they pasted an + * address where a key belongs. + */ +const PARAM_BYTES: Record<string, number> = { + bytes32: 32, + pubkey: 32, +}; + +/** What a type of fixed width should look like, for a refusal that can be acted on. */ +const SHAPES: Record<string, string> = { + bytes32: "32 bytes as 64 hexadecimal characters", + pubkey: "an x-only public key: 32 bytes as 64 hexadecimal characters, no prefix and no address", +}; + /** * Resolves the compile-time parameters a contract is built with, from the manifest's * wiring and what the request and the deployment supply. @@ -84,6 +105,16 @@ export function resolveCompileParams( }; } + const width = PARAM_BYTES[declaredType ?? ""]; + + if (width !== undefined) { + const malformed = wrongWidth(found.value, width, declaredType ?? "", name, reference); + + if (malformed) { + return { ok: false, reason: malformed }; + } + } + // A boolean is written as itself rather than as bytes: the compiler reads `true` and // `false`, and a hex-prefixed one is not an expression of that type. resolved[name] = @@ -125,3 +156,30 @@ function booleanLiteral(value: string): string { function withHexPrefix(value: string): string { return value.startsWith("0x") ? value : `0x${value}`; } + +/** + * Whether a value can be what its declared type says, by shape alone. + * + * It says which compile parameter, which reference, what arrived and what was needed, + * because all four are things the person filling the request can act on and none of + * them survives into the compiler's own message. + */ +function wrongWidth( + value: string, + bytes: number, + declaredType: string, + name: string, + reference: string, +): string | undefined { + const digits = value.startsWith("0x") ? value.slice(2) : value; + + if (digits.length === bytes * 2 && /^[0-9a-fA-F]+$/.test(digits)) { + return undefined; + } + + const found = /^[0-9a-fA-F]*$/.test(digits) + ? `${digits.length} hexadecimal characters` + : `"${value.length > 24 ? `${value.slice(0, 24)}…` : value}"`; + + return `${name} is wired to ${reference}, declared ${declaredType}, which is ${SHAPES[declaredType] ?? `${bytes} bytes`}. Got ${found}.`; +} From 9be44e99e3725cc7a49f01ad64d528b75ebe4339 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 18:11:00 +0300 Subject: [PATCH 062/124] fix(liquid): read the contract identity of the account whose page you are on The row read the selected account. The page it sits on is per-account and reached from a list, so opening a non-selected account's settings showed the selected account's address and key with nothing to say so. Those are the two values someone then funds and locks a covenant to, so the mismatch is not cosmetic: it sends money to an account that cannot spend it. The account is named in the request now rather than assumed, and an unknown one is an error instead of a silent fallback. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- apps/extension/src/background.ts | 20 ++++++++- .../application/contractIdentity.test.ts | 44 +++++++++++++++++++ .../chains/liquid/contractIdentityClient.ts | 16 +++++-- .../internal-rpc/liquid-contract.ts | 18 +++++--- .../Account/components/AccountDetailView.tsx | 2 +- .../components/ContractIdentityRow.tsx | 5 ++- 6 files changed, 91 insertions(+), 14 deletions(-) diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 8ca0451..3c7ab74 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -2,6 +2,7 @@ import browser from "webextension-polyfill"; import { createAccountRegistry } from "@/core/accounts/application/account-registry"; import type { AccountModelState } from "@/core/accounts/application/account-registry/model/account-model"; +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; import type { ActivityPage, EstimateMaxSendInput, @@ -283,11 +284,26 @@ const init = async () => { // the same as the receive address above: the contract SDK signs with one key at a fixed // path and returns change to that key's own unblinded address, so a covenant action can // only spend what sits there. Reading it is what makes that limit visible. - const readContractIdentity = async (): Promise<LiquidContractIdentity> => { + const readContractIdentity = async ( + accountGroupId?: AccountGroupId, + ): Promise<LiquidContractIdentity> => { const { input } = await resolveSelectedLiquidAccount(); + // The settings page is per-account, and the account it shows is not necessarily the + // selected one. Reading the selected account's identity there would put one + // account's address and key on another account's screen, with nothing to say so — + // and the values are what someone then funds and locks a covenant to. + const group = + accountGroupId === undefined + ? undefined + : input.keyManagerState.accountModel.accountGroups[accountGroupId]; + + if (accountGroupId !== undefined && !group) { + throw new Error(`No account group ${accountGroupId}.`); + } + return readLiquidContractIdentity({ - accountGroupIndex: input.accountGroupIndex, + accountGroupIndex: group ? (group.groupIndex ?? 0) : input.accountGroupIndex, chain: input.chain, keyManagerState: input.keyManagerState, }); diff --git a/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts index 45f03b5..03ad69d 100644 --- a/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts @@ -80,3 +80,47 @@ describe("the contract signing identity", () => { await expect(read).rejects.toThrow("signet"); }); }); + +// The screen this serves is per-account, and the account it shows is not necessarily the +// selected one. Reading the selected account's identity there would put one account's +// address and key on another account's screen with nothing to say so — and those are the +// values someone then funds and locks a covenant to. +describe("which account it reads", () => { + test("follows the group index it is given, so two accounts do not answer alike", async () => { + const seen: number[] = []; + const spy = { + loadSmplx: async () => ({ + WalletSigner: class { + constructor( + readonly mnemonic: string, + readonly network: string, + ) {} + address() { + return ADDRESS; + } + free() {} + schnorrPublicKey() { + return KEY; + } + }, + }), + withMnemonic: async ( + request: { accountGroupIndex: number }, + use: (mnemonic: string) => unknown, + ): Promise<unknown> => { + seen.push(request.accountGroupIndex); + + return use(`mnemonic for ${request.accountGroupIndex}`); + }, + } as never; + + for (const accountGroupIndex of [0, 3]) { + await readLiquidContractIdentity( + { accountGroupIndex, chain: chain("testnet"), keyManagerState: {} as never }, + spy, + ); + } + + expect(seen).toEqual([0, 3]); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/contractIdentityClient.ts b/apps/extension/src/core/chains/liquid/contractIdentityClient.ts index c7c1287..88217de 100644 --- a/apps/extension/src/core/chains/liquid/contractIdentityClient.ts +++ b/apps/extension/src/core/chains/liquid/contractIdentityClient.ts @@ -1,14 +1,22 @@ -import { liquidContractRpc } from "@/core/extension-background/internal-rpc/liquid-contract"; +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; +import { + type LiquidContractIdentityInput, + liquidContractRpc, +} from "@/core/extension-background/internal-rpc/liquid-contract"; import { requestBackground } from "@/core/extension-rpc"; import type { LiquidContractIdentity } from "./application/contractIdentity"; /** - * Reads the address and key contract actions are signed with, for the selected account. + * Reads the address and key contract actions are signed with, for one account. * * Popup-side only. The background holds the contract module and the key material; this * asks it for the two public values and nothing else. */ -export function readLiquidContractIdentity(): Promise<LiquidContractIdentity> { - return requestBackground<LiquidContractIdentity>(liquidContractRpc.methods.identity); +export function readLiquidContractIdentity( + accountGroupId: AccountGroupId, +): Promise<LiquidContractIdentity> { + return requestBackground<LiquidContractIdentity>(liquidContractRpc.methods.identity, { + accountGroupId, + } satisfies LiquidContractIdentityInput); } diff --git a/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts b/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts index 36fa307..328894e 100644 --- a/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts +++ b/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts @@ -1,3 +1,4 @@ +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; import type { RequestHandlerMap } from "../transport"; @@ -8,15 +9,22 @@ export const liquidContractRpc = { }, } as const; +export type LiquidContractIdentityInput = { accountGroupId?: AccountGroupId }; + /** - * Reads the address and key that contract actions are signed with, for the selected - * account. Popup-only: the transport dispatches injected senders to a separate - * registry, so a dapp cannot reach this. + * Reads the address and key that contract actions are signed with, for one account. + * + * Popup-only: the transport dispatches injected senders to a separate registry, so a + * dapp cannot reach this. The account is named rather than assumed to be the selected + * one, because the screen this serves is per-account and the two differ. */ export function createLiquidContractInternalHandlers( - readContractIdentity: () => Promise<LiquidContractIdentity>, + readContractIdentity: (accountGroupId?: AccountGroupId) => Promise<LiquidContractIdentity>, ): RequestHandlerMap { return { - [liquidContractRpc.methods.identity]: () => readContractIdentity(), + [liquidContractRpc.methods.identity]: (message) => + readContractIdentity( + (message.data as LiquidContractIdentityInput | undefined)?.accountGroupId, + ), }; } diff --git a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx index 5c7ff1b..748f8e1 100644 --- a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx +++ b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx @@ -113,7 +113,7 @@ export function AccountDetailView({ > <SettingsRowContent icon={PlugSocketIcon} label="Connected dapps" /> </Link> - <ContractIdentityRow /> + <ContractIdentityRow accountGroupId={accountGroupId} /> <button className={cn(settingsRowClass, "text-destructive hover:bg-destructive/10")} onClick={() => setRemoveOpen(true)} diff --git a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx index e87928c..1553da3 100644 --- a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx +++ b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx @@ -1,6 +1,7 @@ import { CodeIcon } from "@hugeicons/core-free-icons"; import { useState } from "react"; +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; import { readLiquidContractIdentity } from "@/core/chains/liquid/contractIdentityClient"; import { @@ -40,7 +41,7 @@ function Value({ hint, label, value }: { hint: string; label: string; value: str * is read on demand rather than with the page because reading it loads the contract * module, which is several megabytes. */ -export function ContractIdentityRow() { +export function ContractIdentityRow({ accountGroupId }: { accountGroupId: AccountGroupId }) { const [identity, setIdentity] = useState<LiquidContractIdentity>(); const [error, setError] = useState<string>(); const [loading, setLoading] = useState(false); @@ -50,7 +51,7 @@ export function ContractIdentityRow() { setError(undefined); try { - setIdentity(await readLiquidContractIdentity()); + setIdentity(await readLiquidContractIdentity(accountGroupId)); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not read the contract identity."); } finally { From 6baf6ee71be08deb0e725d97d49c287c93740022 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 18:13:22 +0300 Subject: [PATCH 063/124] fix(ci): build the wasm the gate actually needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check workflow was written on the assumption that nothing in the gate touches the wasm packages, with a comment claiming the tests skip themselves without them. Measuring a clean non-recursive clone before pushing disproved it: bun install fails on both packages, typecheck reports eleven missing-declaration errors, and three test files fail on "Cannot find module 'smplx-wasm/smplx_wasm_bg.js'" — they drive the real module, which is the point of them. Recursive checkout and both wasm builds, on the dev profile: this job checks code rather than shipping it, so an unoptimised wasm is the right trade. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .github/workflows/check.yml | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 05a9f20..ddd0dcd 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,8 +1,8 @@ name: Check # Runs the same gate a commit runs locally, on every push and pull request. Until this -# existed nothing in CI ran the tests at all: the only workflows were manual builds and -# deploys, so 451 tests protected nothing that could block a merge. +# existed nothing in CI ran the tests at all: the only workflows were a manual extension +# build and two deploys, so 456 tests protected nothing that could block a merge. on: push: branches: ["**"] @@ -15,21 +15,35 @@ jobs: check: runs-on: ubuntu-latest steps: - # Not recursive. The wasm submodules take minutes to build and nothing here - # imports them: the tests that touch the SDK load a prebuilt package and skip - # themselves when it is absent, and typechecking reads its .d.ts from the - # workspace link. A gate that needs a Rust toolchain is a gate people turn off. - - name: Checkout + # Recursive, and both wasm packages are built before anything else runs. A leaner + # job was tried and does not work: on a checkout without them `bun install` reports + # "Failed to install 2 packages", typechecking fails with eleven errors about + # `lwk_wasm` and `smplx-wasm` having no declarations, and three test files fail + # outright on `Cannot find module 'smplx-wasm/smplx_wasm_bg.js'` — they drive the + # real module rather than a substitute, which is the point of them. + - name: Checkout (with the lwk and smplx submodules) uses: actions/checkout@v4 + with: + submodules: recursive - name: Setup Bun uses: ./.github/actions/setup-bun + # dev rather than release: this job checks code, and an unoptimised wasm builds + # faster. The release profile belongs to the build workflow, which ships the result. + - name: Build lwk_wasm + uses: ./.github/actions/build-lwk-wasm + with: + profile: dev + + - name: Build smplx_wasm + uses: ./.github/actions/build-smplx-wasm + - name: Install dependencies uses: ./.github/actions/install # typecheck across apps/extension, packages/ and apps/web, then lint, format and - # the test suite. The three projects are separate deliberately — see the comment - # in lefthook.yml for why one `tsc --noEmit` never covered them. + # the test suite. The three projects are separate deliberately — see the comment in + # lefthook.yml for why one `tsc --noEmit` never covered them. - name: Check run: bun run check From 35657b7654fba50d4302b02f9dd26d49d6c89a8c Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 18:17:33 +0300 Subject: [PATCH 064/124] fix(liquid): show the confirmation screen, which nothing was ever asked to render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling the method opened a black window that timed out into "User rejected the request". Two faults stacked, and neither could be seen from the code that contained them. The method put `{action, broadcast, covenants, kind, protocol}` on the payload. The renderer's guard requires `shown` to be an object, so it matched nothing, returned null, and the window rendered empty until the confirmation timed out — which the caller sees as a rejection, because that is what a timeout means here. Underneath that, the model carries `bigint` amounts and the payload crosses the extension's message bus, which serializes as JSON. `JSON.stringify` throws on a bigint rather than losing it, so a corrected payload would have failed differently. Amounts stay bigint everywhere they are computed and become decimal strings at that one boundary — `toShownConfirmation`. The reason both shipped is the same: the confirmation was only ever driven from data a test wrote by hand. It is now driven from the method, and the two things that broke are what the new tests assert — the renderer's own guard accepts the real payload, and the real payload survives JSON. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../ProcessCtConfirmation.tsx | 14 ++++-- .../index.test.ts | 50 +++++++++++++++++++ .../processConfidentialTransaction/index.ts | 14 +++--- packages/tx-manifest/src/confirmation.ts | 28 ++++++++++- packages/tx-manifest/src/index.ts | 8 ++- 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx index 9a1ac9f..a47cb62 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx @@ -1,4 +1,4 @@ -import { type ConfirmationModel, describeOrigin } from "@humid/tx-manifest"; +import { describeOrigin, type ShownConfirmation } from "@humid/tx-manifest"; import type { Provenanced } from "@humid/tx-manifest"; import type { ConfirmationRenderer } from "@/common/Confirmation"; @@ -10,7 +10,7 @@ export const PROCESS_CT_CONFIRMATION_KIND = "liquid.processConfidentialTransacti export type ProcessCtConfirmationData = { broadcast: boolean; kind: typeof PROCESS_CT_CONFIRMATION_KIND; - shown: ConfirmationModel; + shown: ShownConfirmation; }; export function isProcessCtConfirmationData(value: unknown): value is ProcessCtConfirmationData { @@ -43,8 +43,14 @@ function Shown({ label, value }: { label: string; value: Provenanced<string> }) ); } -/** Base units as a person reads them, keeping the sign that says which way the money goes. */ -function amount(sats: bigint): string { +/** + * Base units as a person reads them, keeping the sign that says which way the money goes. + * + * Takes the decimal string the wire form carries rather than a bigint: the model crosses + * a JSON boundary to get here, and JSON cannot carry one. + */ +function amount(value: string): string { + const sats = BigInt(value); const negative = sats < 0n; const whole = (negative ? -sats : sats).toString().padStart(9, "0"); const point = `${whole.slice(0, -8)}.${whole.slice(-8)}`.replace(/\.?0+$/, ""); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 43488c8..e49ca6d 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -8,6 +8,10 @@ import { type LiquidProcessCtContext, type LiquidProcessCtDependencies, } from "./index"; +import { + isProcessCtConfirmationData, + type ProcessCtConfirmationData, +} from "./ProcessCtConfirmation"; // Drives the whole seam — parse, verify, plan, sign, broadcast — with substituted // dependencies. What is asserted is the method's own behaviour: what it refuses, what it @@ -316,3 +320,49 @@ describe("processLiquidConfidentialTransaction on a restored wallet", () => { ]); }); }); + +// The confirmation screen was never driven from the method, only from data a test wrote +// by hand — so a payload that no renderer could read shipped, and a person calling the +// method got a black window that timed out into "User rejected the request" (DISC-137). +// Both halves of that are asserted here against the real payload. +describe("what the person is actually shown", () => { + async function shownRequest() { + let captured: { data?: unknown } | undefined; + + await subject().method(params(), { + ...context(), + authorization: { isGranted: () => false }, + confirm: async (request: { data?: unknown }) => { + captured = request; + + return true; + }, + } as unknown as LiquidProcessCtContext); + + return captured; + } + + test("the payload is one the confirmation surface recognises", async () => { + const request = await shownRequest(); + + expect(isProcessCtConfirmationData(request?.data)).toBe(true); + }); + + test("and survives the message bus, which serializes as JSON and cannot carry a bigint", async () => { + const request = await shownRequest(); + + expect(() => JSON.stringify(request?.data)).not.toThrow(); + }); + + test("carrying the wallet's own figures, not the site's claims", async () => { + const request = await shownRequest(); + const data = request?.data as ProcessCtConfirmationData; + + expect(data.shown.netEffect.length).toBeGreaterThan(0); + // `computed` rather than `verified`: the balance change is arithmetic over chain + // reads, and combining takes the weaker origin so the sum cannot claim more than its + // parts. What matters on this screen is that it is not the site's word. + expect(data.shown.netEffect[0]?.sats.origin).toBe("computed"); + expect(data.shown.protocol.origin).toBe("site"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 633efa7..57bcdab 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -9,6 +9,7 @@ import { type ReadFeeRate, type ReadTxOut, reviewManifestAction, + toShownConfirmation, } from "@humid/tx-manifest"; import type { KeyManagerState, UpdateKeyManagerState } from "@/core/key-manager/types"; @@ -130,14 +131,15 @@ export const createProcessLiquidConfidentialTransaction = ( >({ confirmation: ({ params, review }) => ({ data: { - action: review.action, broadcast: params.broadcast, - // Every covenant the wallet rebuilt, with what it established about each. - // `not-yet-on-chain` marks one being created, which there is nothing to compare - // against — it is a different fact, not a weaker form of verified. - covenants: review.covenants, kind: PROCESS_CT_CONFIRMATION_KIND, - protocol: review.protocol, + // The whole model the person is shown, amounts as strings: this crosses the + // message bus, which serializes as JSON, and JSON.stringify throws on a bigint + // rather than rounding it. Every covenant the wallet rebuilt is inside it, with + // what it established about each — `not-yet-on-chain` marks one being created, + // which there is nothing to compare against and is a different fact rather than + // a weaker form of verified. + shown: toShownConfirmation(review.confirmation), }, message: `A site wants to perform "${review.action}" on the ${review.protocol} protocol.`, title: "Perform a contract action?", diff --git a/packages/tx-manifest/src/confirmation.ts b/packages/tx-manifest/src/confirmation.ts index c724e26..bf7d477 100644 --- a/packages/tx-manifest/src/confirmation.ts +++ b/packages/tx-manifest/src/confirmation.ts @@ -1,6 +1,6 @@ import { asRecord } from "./json"; import type { NormalisedAction, NormalisedManifest } from "./normalise"; -import { computed, fromSite, type Provenanced, type Origin, verified } from "./provenance"; +import { computed, fromSite, map, type Origin, type Provenanced, verified } from "./provenance"; import type { ManifestReview } from "./review"; /** One asset's movement in or out of the wallet, as a person would read it. */ @@ -122,3 +122,29 @@ function actionSummary(action: NormalisedAction): string | undefined { return typeof declared === "string" ? declared : undefined; } + +/** + * The same model with every amount as a decimal string. + * + * It exists because the confirmation crosses the extension's message bus, which + * serializes as JSON, and JSON has no bigint — `JSON.stringify` throws on one rather + * than losing it. Amounts stay bigint everywhere they are computed and become strings + * only at that boundary, which is the one place the loss is a formatting concern rather + * than an arithmetic one. + */ +export type ShownConfirmation = Omit<ConfirmationModel, "feeSats" | "netEffect"> & { + feeSats: Provenanced<string>; + netEffect: { asset: Provenanced<string>; sats: Provenanced<string> }[]; +}; + +/** Prepares the model to cross a boundary that cannot carry a bigint. */ +export function toShownConfirmation(model: ConfirmationModel): ShownConfirmation { + return { + ...model, + feeSats: map(model.feeSats, (sats) => sats.toString()), + netEffect: model.netEffect.map((effect) => ({ + asset: effect.asset, + sats: map(effect.sats, (sats) => sats.toString()), + })), + }; +} diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts index fa082d9..4dac494 100644 --- a/packages/tx-manifest/src/index.ts +++ b/packages/tx-manifest/src/index.ts @@ -15,7 +15,13 @@ export { createEsploraTxOutReader, encodeExplicitTxOut, } from "./chainRead"; -export { type ConfirmationModel, confirmationModel, describeOrigin } from "./confirmation"; +export { + type ConfirmationModel, + type ShownConfirmation, + confirmationModel, + describeOrigin, + toShownConfirmation, +} from "./confirmation"; export { estimateFeeSats, estimateVsize } from "./fee"; export { guardSpentInputs } from "./inputGuard"; export { findAction, normaliseManifest } from "./normalise"; From 26f6253804876515dbcd9a140a5bb8e8352fc814 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 19:15:21 +0300 Subject: [PATCH 065/124] fix(liquid): pay a covenant output its script, not the address it is shown as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Invalid script: Odd number of digits" — the transaction builder hex-decodes every output script it is given, and it was given `tex1p…`. A covenant output was paid to the bech32 address the wallet derived, because the address and the scriptPubKey were two spellings of one fact reached by two separate calls, and only one of them is hex. They now come from one compiled contract. The second call had already drifted in a way nothing would have caught: `scriptPubKeyOf` built its Contract without the extra taproot leaves, so for any covenant declaring them it derived the script of a different contract than the address. The test substitute's `addOutput` accepted anything and recorded nothing, which is why a bech32 string reached the module unremarked. It records now, and two tests assert what the real builder requires: every output script is decodable hex, and the covenant output pays the script rather than the address. Reintroducing the bug fails both. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../index.test.ts | 47 +++++++++++++++++-- .../processConfidentialTransaction/index.ts | 19 ++++++-- packages/tx-manifest/src/covenant.test.ts | 8 +++- packages/tx-manifest/src/covenant.ts | 30 ++++++++++-- packages/tx-manifest/src/review.test.ts | 10 +++- packages/tx-manifest/src/review.ts | 8 +++- 6 files changed, 107 insertions(+), 15 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index e49ca6d..7c94c57 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -20,6 +20,7 @@ import { const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const SOURCE_PATH = "./p2pk.simf"; const SOURCE = "fn main() { }"; +const DERIVED_SCRIPT = "5120" + "aa".repeat(32); const DERIVED = "tex1p_derived"; const WALLET_ADDRESS = "tex1q_wallet"; const WALLET_SCRIPT = "0014" + "11".repeat(20); @@ -80,7 +81,7 @@ function context(): LiquidProcessCtContext { } as unknown as LiquidProcessCtContext; } -type Recorded = { broadcasts: { txHex: string }[]; mnemonicCalls: number }; +type Recorded = { broadcasts: { txHex: string }[]; mnemonicCalls: number; paid: string[] }; function dependencies(recorded: Recorded): LiquidProcessCtDependencies { return { @@ -96,8 +97,11 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { covenantAddress() { return DERIVED; } + // Held across the wasm boundary, so the method releases it. A substitute + // without this passes only because nothing checked that it was released. + free() {} scriptPubKeyHex() { - return "5120aabb"; + return DERIVED_SCRIPT; } }, TransactionBuilder: class { @@ -105,7 +109,12 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { addCovenantInput(txid: string, vout: number) { this.spends.push({ txid, vout }); } - addOutput() {} + // Records what it was handed rather than ignoring it. The real builder + // hex-decodes this, so a substitute that accepts anything is how an output + // paid to a bech32 address reached the module (DISC-138). + addOutput(scriptPubKeyHex: string) { + recorded.paid.push(scriptPubKeyHex); + } addWalletInput(txid: string, vout: number) { this.spends.push({ txid, vout }); } @@ -145,7 +154,7 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { } function subject() { - const recorded: Recorded = { broadcasts: [], mnemonicCalls: 0 }; + const recorded: Recorded = { broadcasts: [], mnemonicCalls: 0, paid: [] }; return { method: createProcessLiquidConfidentialTransaction(dependencies(recorded)), recorded }; } @@ -232,7 +241,7 @@ describe("processLiquidConfidentialTransaction across declaration shapes", () => // other part of the request was well formed. describe("processLiquidConfidentialTransaction guards what it signs", () => { function subjectSpending(extra: { txid: string; vout: number }) { - const recorded: Recorded = { broadcasts: [], mnemonicCalls: 0 }; + const recorded: Recorded = { broadcasts: [], mnemonicCalls: 0, paid: [] }; const dependency = dependencies(recorded); return { @@ -366,3 +375,31 @@ describe("what the person is actually shown", () => { expect(data.shown.protocol.origin).toBe("site"); }); }); + +// The transaction builder hex-decodes every output script it is given, so a value that is +// not hex fails inside the module with "Invalid script: Odd number of digits" — an error +// that names neither the output nor what was wrong with it. A covenant output was paid to +// the bech32 address the wallet derived, because the address and the scriptPubKey were two +// spellings of one fact reached by two different calls (DISC-138). +describe("what the outputs actually pay to", () => { + test("every output script is hex the builder can decode", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.paid.length).toBeGreaterThan(0); + + for (const script of recorded.paid) { + expect(script).toMatch(/^(?:[0-9a-fA-F]{2})+$/); + } + }); + + test("and the covenant output pays the script, not the address it is shown as", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.paid).toContain(DERIVED_SCRIPT); + expect(recorded.paid).not.toContain(DERIVED); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 57bcdab..deda904 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -251,19 +251,32 @@ export const createProcessLiquidConfidentialTransaction = ( await context.walletBackend.syncAccount(account); const result = await reviewManifestAction(params, { + // One compiled contract, two spellings of where the covenant is. Deriving them + // from separate compiles is how an output came to be paid to a bech32 string: + // the builder hex-decodes what it is given, and an address is not hex. compile: ({ argumentsJson, extraLeavesJson, includeDebugSymbols, network: target, source, - }) => - new smplx.Contract( + }) => { + const contract = new smplx.Contract( source, argumentsJson, extraLeavesJson, includeDebugSymbols, - ).covenantAddress(target), + ); + + try { + return { + address: contract.covenantAddress(target), + scriptPubKeyHex: contract.scriptPubKeyHex(target), + }; + } finally { + contract.free(); + } + }, compilerVersion: smplx.compilerVersion(), policyAsset: account.rawPolicyAssetId, scriptPubKeyOf: ({ argumentsJson, source }) => diff --git a/packages/tx-manifest/src/covenant.test.ts b/packages/tx-manifest/src/covenant.test.ts index bb6bacd..a184cfe 100644 --- a/packages/tx-manifest/src/covenant.test.ts +++ b/packages/tx-manifest/src/covenant.test.ts @@ -30,6 +30,8 @@ function request(overrides: Record<string, unknown> = {}) { } /** Stands in for the wasm module, recording what it was asked to compile. */ +const SCRIPT = `5120${"11".repeat(32)}`; + function compiler(address = "tex1p_derived") { const calls: { argumentsJson: string; @@ -49,7 +51,10 @@ function compiler(address = "tex1p_derived") { }) => { calls.push(input); - return address; + // Both spellings from one compile: the address a person is shown, and the script + // an output pays to. Only the second is hex, and confusing them is how a bech32 + // string reached the transaction builder. + return { address, scriptPubKeyHex: SCRIPT }; }, }; } @@ -103,6 +108,7 @@ describe("covenantMatchesChain", () => { address: "tex1p_derived", argumentsJson: "{}", extraLeavesJson: "[]", + scriptPubKeyHex: SCRIPT, source: SOURCE, utxoType: "p2pk_output", }; diff --git a/packages/tx-manifest/src/covenant.ts b/packages/tx-manifest/src/covenant.ts index 0cde0f6..a3a425b 100644 --- a/packages/tx-manifest/src/covenant.ts +++ b/packages/tx-manifest/src/covenant.ts @@ -18,11 +18,28 @@ export type CompileCovenant = (input: { includeDebugSymbols: boolean; network: string; source: string; -}) => Promise<string> | string; +}) => Promise<CompiledCovenant> | CompiledCovenant; + +/** + * What one compile yields: where the covenant is, in both forms a transaction needs. + * + * Both come from the same compiled contract rather than from two calls, because they are + * two spellings of one fact. Deriving them separately is how an output came to be paid + * to a bech32 string — the transaction builder takes a scriptPubKey and hex-decodes it, + * and an address is not hex. + */ +export type CompiledCovenant = { + /** What a person is shown, and what an on-chain output is compared against. */ + address: string; + /** What an output actually pays to. */ + scriptPubKeyHex: string; +}; export type CovenantDerivation = { /** The address the wallet derived by rebuilding the contract itself. */ address: string; + /** The same covenant as an output pays it, from the same compile. */ + scriptPubKeyHex: string; /** The extra taproot leaves it was built with, encoded. */ extraLeavesJson: string; /** @@ -106,7 +123,7 @@ export async function deriveCovenantAddress( const extraLeavesJson = JSON.stringify(leaves.hex); try { - const address = await input.compile({ + const compiled = await input.compile({ argumentsJson, extraLeavesJson, includeDebugSymbols: input.includeDebugSymbols, @@ -115,7 +132,14 @@ export async function deriveCovenantAddress( }); return { - derivation: { address, argumentsJson, extraLeavesJson, source, utxoType: input.utxoType }, + derivation: { + address: compiled.address, + argumentsJson, + extraLeavesJson, + scriptPubKeyHex: compiled.scriptPubKeyHex, + source, + utxoType: input.utxoType, + }, ok: true, }; } catch (error) { diff --git a/packages/tx-manifest/src/review.test.ts b/packages/tx-manifest/src/review.test.ts index da83dd6..6ec22c5 100644 --- a/packages/tx-manifest/src/review.test.ts +++ b/packages/tx-manifest/src/review.test.ts @@ -12,6 +12,11 @@ const SOURCE = "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; const TXID = "b".repeat(64); const DERIVED = "tex1p_derived"; +// A compile yields both spellings of where the covenant is. They are distinct on purpose: +// the address is what a person is shown and what an on-chain output is compared against, +// the scriptPubKey is what an output pays to, and only one of them is hex. +const DERIVED_SCRIPT = "5120" + "11".repeat(32); +const COMPILED = { address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }; function request( overrides: Partial<ParsedLiquidProcessCtParams> = {}, @@ -26,7 +31,7 @@ function request( }; } -const compile = () => DERIVED; +const compile = () => COMPILED; const WALLET_SCRIPT = "0014" + "11".repeat(20); const readFeeRate = async () => 1000; const fundingUtxos = [ @@ -85,6 +90,7 @@ describe("reviewManifestAction", () => { { address: DERIVED, role: "created", + scriptPubKeyHex: DERIVED_SCRIPT, utxoType: "p2pk_output", verified: "not-yet-on-chain", }, @@ -503,7 +509,7 @@ describe("the mode a protocol declares reaches the compiler", () => { compile: (input) => { seen.push(input.includeDebugSymbols); - return DERIVED; + return COMPILED; }, readTxOut: readTxOut("unused"), }).then((result) => ({ result, seen })); diff --git a/packages/tx-manifest/src/review.ts b/packages/tx-manifest/src/review.ts index 1e4d5e8..e4512ef 100644 --- a/packages/tx-manifest/src/review.ts +++ b/packages/tx-manifest/src/review.ts @@ -31,6 +31,8 @@ import { checkValidations } from "./validate"; */ export type CovenantFinding = { address: string; + /** What an output pays to, which is not the address and is not interchangeable with it. */ + scriptPubKeyHex: string; role: "created" | "spent"; utxoType: string; verified: "matches-chain" | "not-yet-on-chain"; @@ -237,6 +239,7 @@ export async function reviewManifestAction( covenants.push({ address: derived.derivation.address, role: "created", + scriptPubKeyHex: derived.derivation.scriptPubKeyHex, utxoType: site.utxoType, verified: "not-yet-on-chain", }); @@ -298,6 +301,7 @@ export async function reviewManifestAction( covenants.push({ address: derived.derivation.address, role: "spent", + scriptPubKeyHex: derived.derivation.scriptPubKeyHex, utxoType: site.utxoType, verified: "matches-chain", }); @@ -361,7 +365,9 @@ export async function reviewManifestAction( return { reason: failed.reason, refused: true }; } - const covenantScripts = new Map(covenants.map((found) => [found.utxoType, found.address])); + const covenantScripts = new Map( + covenants.map((found) => [found.utxoType, found.scriptPubKeyHex]), + ); const outputs: ReviewedOutput[] = []; for (const planned of plan.plan.outputs) { From a962c6adcd73eabbf8675db5cdc9b407d0826d06 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 20:30:40 +0300 Subject: [PATCH 066/124] fix(liquid): never fund a contract action from a confidential output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet receives to confidential addresses, so most of what it holds is blinded. A contract action cannot spend one: unblinding needs the secrets that go with it, and the signing module is handed an outpoint and its bytes and nothing else. Selection is largest-first, so it would have reached for the biggest output the account holds — which on any real wallet is a blinded one — and built a transaction that fails inside the module, far from the output that caused it. The flag was already on every UTXO the backend returns. `SelectableUtxo` never declared it, so structural typing dropped it silently at the boundary and the runtime could not see what it was being handed. A shortfall now says how much is held back and what to do about it, because being told you are short of money you can see on your own balance is not an explanation. Found by auditing the remaining test substitutes rather than by a fourth failed attempt: the last two faults both reached a person through a substitute that accepted whatever it was given. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../tx-manifest/src/coinSelection.test.ts | 43 +++++++++++++++++++ packages/tx-manifest/src/coinSelection.ts | 29 ++++++++++--- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/tx-manifest/src/coinSelection.test.ts b/packages/tx-manifest/src/coinSelection.test.ts index bb507f0..c57ef50 100644 --- a/packages/tx-manifest/src/coinSelection.test.ts +++ b/packages/tx-manifest/src/coinSelection.test.ts @@ -66,3 +66,46 @@ describe("selectCoins", () => { } }); }); + +// The wallet receives to confidential addresses by default, so most of what it holds is +// blinded. A contract action cannot spend one: unblinding needs the secrets that go with +// it, and the signing module gets an outpoint and its bytes and nothing else. Selecting +// one builds a transaction that fails inside the module, far from the output that caused +// it — and largest-first would reach for the biggest, which is exactly the blinded one +// (DISC-139). +describe("confidential outputs", () => { + const explicit = { amount: "1000", spendable: true, txOut: "00", txid: "a", vout: 0 }; + const blinded = { + amount: "9000", + confidential: true, + spendable: true, + txOut: "00", + txid: "b", + vout: 0, + }; + + test("are never selected, however much they hold", () => { + const result = selectCoins([blinded, explicit], 500n, 0n); + + expect(result.ok && result.selected.map((utxo) => utxo.txid)).toEqual(["a"]); + }); + + test("and when they are why the account falls short, the refusal says so", () => { + const result = selectCoins([blinded, explicit], 5000n, 0n); + + expect(result.ok ? "" : result.reason).toContain("9000"); + expect(result.ok ? "" : result.reason).toContain("unblinded address"); + }); + + test("an account holding only blinded outputs is short of all of it", () => { + const result = selectCoins([blinded], 100n, 0n); + + expect(result.ok).toBe(false); + }); + + test("nothing withheld leaves the refusal as it was", () => { + const result = selectCoins([explicit], 5000n, 0n); + + expect(result.ok ? "" : result.reason).not.toContain("confidential"); + }); +}); diff --git a/packages/tx-manifest/src/coinSelection.ts b/packages/tx-manifest/src/coinSelection.ts index 7231dae..cccc98a 100644 --- a/packages/tx-manifest/src/coinSelection.ts +++ b/packages/tx-manifest/src/coinSelection.ts @@ -1,6 +1,16 @@ /** One wallet output the selector may spend, as the wallet already describes it. */ export type SelectableUtxo = { amount: string; + /** + * Whether this output's amount and asset are hidden on chain. + * + * A confidential one cannot fund a contract action: unblinding it needs the secrets + * that go with it, and the signing module is handed an outpoint and its bytes and + * nothing else. Selecting one produces a transaction that fails inside the module, + * far from the output that caused it. Optional because a caller assembling a list by + * hand has nothing to hide. + */ + confidential?: boolean; /** * Where this output pays, when the wallet knows it. * @@ -40,10 +50,15 @@ export function selectCoins( } const needed = targetSats + headroomSats; - const spendable = available - .filter((utxo) => utxo.spendable) - .slice() - .sort((a, b) => (toSats(b.amount) > toSats(a.amount) ? 1 : -1)); + const usable = available.filter((utxo) => utxo.spendable && !utxo.confidential); + const spendable = usable.slice().sort((a, b) => (toSats(b.amount) > toSats(a.amount) ? 1 : -1)); + + // What is there and cannot be used, so a refusal can say so. A person looking at a + // balance that covers the amount needs to be told why it does not count, rather than + // being told they are short of money they can see. + const withheld = available + .filter((utxo) => utxo.spendable && utxo.confidential) + .reduce((sum, utxo) => sum + toSats(utxo.amount), 0n); const selected: SelectableUtxo[] = []; let totalSats = 0n; @@ -60,7 +75,11 @@ export function selectCoins( if (totalSats < needed) { return { ok: false, - reason: `This account holds ${totalSats} of the ${needed} needed to perform the action and pay its fee.`, + reason: + `This account holds ${totalSats} of the ${needed} needed to perform the action and pay its fee.` + + (withheld > 0n + ? ` A further ${withheld} is in confidential outputs, which a contract action cannot spend — send it to this account's unblinded address to use it.` + : ""), }; } From 63bfd312d78e728280d1f5399f1f99c00a789cc8 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Tue, 4 Aug 2026 20:32:52 +0300 Subject: [PATCH 067/124] test(liquid): make every builder substitute as strict as the module it stands for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults reached a person through a substitute that accepted whatever it was given: an output paid to a bech32 address, and before that a confirmation payload no renderer could read. The suite was green for both. So the substitutes now apply the module's own rule — what it decodes, they decode. Every hex argument is parsed, every txid is checked for length, and the change script `finalizeTransaction` receives is parsed rather than ignored. This closes the class rather than the two instances. Nothing new fails, which is the point: the checks are there for the next argument someone passes in the wrong form. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../index.test.ts | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 7c94c57..68297a0 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -20,7 +20,20 @@ import { const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const SOURCE_PATH = "./p2pk.simf"; const SOURCE = "fn main() { }"; -const DERIVED_SCRIPT = "5120" + "aa".repeat(32); +const DERIVED_SCRIPT = `5120${"aa".repeat(32)}`; + +/** What the real module does with a hex argument, so a substitute cannot be laxer. */ +function requireHex(what: string, value: string): void { + if (!/^(?:[0-9a-fA-F]{2})+$/.test(value)) { + throw new Error(`Invalid ${what}: Odd number of digits`); + } +} + +function requireTxid(txid: string): void { + if (!/^[0-9a-fA-F]{64}$/.test(txid)) { + throw new Error(`Invalid txid: ${txid}`); + } +} const DERIVED = "tex1p_derived"; const WALLET_ADDRESS = "tex1q_wallet"; const WALLET_SCRIPT = "0014" + "11".repeat(20); @@ -104,24 +117,36 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { return DERIVED_SCRIPT; } }, + // Every argument the real builder parses is parsed here too. A substitute that + // accepts whatever it is given is how a bech32 address reached `addOutput` + // through a green suite (DISC-138), so the rule is now the module's own: what + // it decodes, this decodes. TransactionBuilder: class { spends: { txid: string; vout: number }[] = []; - addCovenantInput(txid: string, vout: number) { + addCovenantInput(txid: string, vout: number, txOutHex: string) { + requireHex("covenant input's previous output", txOutHex); + requireTxid(txid); this.spends.push({ txid, vout }); } - // Records what it was handed rather than ignoring it. The real builder - // hex-decodes this, so a substitute that accepts anything is how an output - // paid to a bech32 address reached the module (DISC-138). addOutput(scriptPubKeyHex: string) { + requireHex("output script", scriptPubKeyHex); recorded.paid.push(scriptPubKeyHex); } - addWalletInput(txid: string, vout: number) { + addWalletInput(txid: string, vout: number, txOut: string) { + requireHex("wallet input's previous output", txOut); + requireTxid(txid); this.spends.push({ txid, vout }); } free() {} }, WalletSigner: class { - finalizeTransaction(builder: { spends: { txid: string; vout: number }[] }) { + finalizeTransaction( + builder: { spends: { txid: string; vout: number }[] }, + _feeRateSatsPerKvb: number, + changeScriptPubKeyHex: string, + ) { + requireHex("change script", changeScriptPubKeyHex); + return { feeSats: 500n, free: () => undefined, From a46041d36a90f8ec145fb139d876487d42342678 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 11:56:10 +0300 Subject: [PATCH 068/124] chore(skills): install the client delivery skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve skills from agent-skills/plugins/client — api-integration, code-quality, components, error-handling, file-structure, forms, localization, logging, native-integration, routing, state-management, user-feedback. Copied into both agent directories, matching how the workflow skills already sit here. Not recorded in skills-lock.json: that file is wfctl's account of what wfctl installed, and these are not its. `wfctl upgrade --dry-run` reports 0 to delete with them present, so they survive an upgrade rather than depending on the lock to protect them. They describe how to write code in this repository. They introduce no workflow step, no gate, and no product decision. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .agents/skills/api-integration/SKILL.md | 376 ++++++++++++ .../references/module-pattern.md | 157 +++++ .../references/query-balancing.md | 245 ++++++++ .../references/transport-examples.md | 133 ++++ .agents/skills/code-quality/SKILL.md | 129 ++++ .agents/skills/components/SKILL.md | 412 +++++++++++++ .agents/skills/error-handling/SKILL.md | 251 ++++++++ .../skills/error-handling/agents/openai.yaml | 4 + .../references/tanstack-query.md | 195 ++++++ .../references/trace-preservation.md | 180 ++++++ .agents/skills/file-structure/SKILL.md | 246 ++++++++ .../skills/file-structure/agents/openai.yaml | 4 + .agents/skills/forms/SKILL.md | 249 ++++++++ .agents/skills/forms/references/patterns.md | 172 ++++++ .agents/skills/localization/SKILL.md | 500 +++++++++++++++ .../localization/references/i18n-check.md | 569 ++++++++++++++++++ .agents/skills/logging/SKILL.md | 327 ++++++++++ .agents/skills/logging/agents/openai.yaml | 4 + .../logging/references/facade-and-records.md | 368 +++++++++++ .../logging/references/platform-transports.md | 339 +++++++++++ .../references/proven-platform-patterns.md | 162 +++++ .../logging/references/trace-origins.md | 308 ++++++++++ .agents/skills/native-integration/SKILL.md | 345 +++++++++++ .agents/skills/routing/SKILL.md | 330 ++++++++++ .agents/skills/routing/agents/openai.yaml | 4 + .../routing/references/tanstack-router.md | 343 +++++++++++ .agents/skills/state-management/SKILL.md | 263 ++++++++ .../references/ownership-and-scope.md | 165 +++++ .../references/persistence-and-security.md | 391 ++++++++++++ .../references/processes-and-coordination.md | 233 +++++++ .../references/store-patterns.md | 211 +++++++ .agents/skills/user-feedback/SKILL.md | 217 +++++++ .../skills/user-feedback/agents/openai.yaml | 4 + .../references/presentation-patterns.md | 173 ++++++ .claude/skills/api-integration/SKILL.md | 376 ++++++++++++ .../references/module-pattern.md | 157 +++++ .../references/query-balancing.md | 245 ++++++++ .../references/transport-examples.md | 133 ++++ .claude/skills/code-quality/SKILL.md | 129 ++++ .claude/skills/components/SKILL.md | 412 +++++++++++++ .claude/skills/error-handling/SKILL.md | 251 ++++++++ .../skills/error-handling/agents/openai.yaml | 4 + .../references/tanstack-query.md | 195 ++++++ .../references/trace-preservation.md | 180 ++++++ .claude/skills/file-structure/SKILL.md | 246 ++++++++ .../skills/file-structure/agents/openai.yaml | 4 + .claude/skills/forms/SKILL.md | 249 ++++++++ .claude/skills/forms/references/patterns.md | 172 ++++++ .claude/skills/localization/SKILL.md | 500 +++++++++++++++ .../localization/references/i18n-check.md | 569 ++++++++++++++++++ .claude/skills/logging/SKILL.md | 327 ++++++++++ .claude/skills/logging/agents/openai.yaml | 4 + .../logging/references/facade-and-records.md | 368 +++++++++++ .../logging/references/platform-transports.md | 339 +++++++++++ .../references/proven-platform-patterns.md | 162 +++++ .../logging/references/trace-origins.md | 308 ++++++++++ .claude/skills/native-integration/SKILL.md | 345 +++++++++++ .claude/skills/routing/SKILL.md | 330 ++++++++++ .claude/skills/routing/agents/openai.yaml | 4 + .../routing/references/tanstack-router.md | 343 +++++++++++ .claude/skills/state-management/SKILL.md | 263 ++++++++ .../references/ownership-and-scope.md | 165 +++++ .../references/persistence-and-security.md | 391 ++++++++++++ .../references/processes-and-coordination.md | 233 +++++++ .../references/store-patterns.md | 211 +++++++ .claude/skills/user-feedback/SKILL.md | 217 +++++++ .../skills/user-feedback/agents/openai.yaml | 4 + .../references/presentation-patterns.md | 173 ++++++ 68 files changed, 16018 insertions(+) create mode 100644 .agents/skills/api-integration/SKILL.md create mode 100644 .agents/skills/api-integration/references/module-pattern.md create mode 100644 .agents/skills/api-integration/references/query-balancing.md create mode 100644 .agents/skills/api-integration/references/transport-examples.md create mode 100644 .agents/skills/code-quality/SKILL.md create mode 100644 .agents/skills/components/SKILL.md create mode 100644 .agents/skills/error-handling/SKILL.md create mode 100644 .agents/skills/error-handling/agents/openai.yaml create mode 100644 .agents/skills/error-handling/references/tanstack-query.md create mode 100644 .agents/skills/error-handling/references/trace-preservation.md create mode 100644 .agents/skills/file-structure/SKILL.md create mode 100644 .agents/skills/file-structure/agents/openai.yaml create mode 100644 .agents/skills/forms/SKILL.md create mode 100644 .agents/skills/forms/references/patterns.md create mode 100644 .agents/skills/localization/SKILL.md create mode 100644 .agents/skills/localization/references/i18n-check.md create mode 100644 .agents/skills/logging/SKILL.md create mode 100644 .agents/skills/logging/agents/openai.yaml create mode 100644 .agents/skills/logging/references/facade-and-records.md create mode 100644 .agents/skills/logging/references/platform-transports.md create mode 100644 .agents/skills/logging/references/proven-platform-patterns.md create mode 100644 .agents/skills/logging/references/trace-origins.md create mode 100644 .agents/skills/native-integration/SKILL.md create mode 100644 .agents/skills/routing/SKILL.md create mode 100644 .agents/skills/routing/agents/openai.yaml create mode 100644 .agents/skills/routing/references/tanstack-router.md create mode 100644 .agents/skills/state-management/SKILL.md create mode 100644 .agents/skills/state-management/references/ownership-and-scope.md create mode 100644 .agents/skills/state-management/references/persistence-and-security.md create mode 100644 .agents/skills/state-management/references/processes-and-coordination.md create mode 100644 .agents/skills/state-management/references/store-patterns.md create mode 100644 .agents/skills/user-feedback/SKILL.md create mode 100644 .agents/skills/user-feedback/agents/openai.yaml create mode 100644 .agents/skills/user-feedback/references/presentation-patterns.md create mode 100644 .claude/skills/api-integration/SKILL.md create mode 100644 .claude/skills/api-integration/references/module-pattern.md create mode 100644 .claude/skills/api-integration/references/query-balancing.md create mode 100644 .claude/skills/api-integration/references/transport-examples.md create mode 100644 .claude/skills/code-quality/SKILL.md create mode 100644 .claude/skills/components/SKILL.md create mode 100644 .claude/skills/error-handling/SKILL.md create mode 100644 .claude/skills/error-handling/agents/openai.yaml create mode 100644 .claude/skills/error-handling/references/tanstack-query.md create mode 100644 .claude/skills/error-handling/references/trace-preservation.md create mode 100644 .claude/skills/file-structure/SKILL.md create mode 100644 .claude/skills/file-structure/agents/openai.yaml create mode 100644 .claude/skills/forms/SKILL.md create mode 100644 .claude/skills/forms/references/patterns.md create mode 100644 .claude/skills/localization/SKILL.md create mode 100644 .claude/skills/localization/references/i18n-check.md create mode 100644 .claude/skills/logging/SKILL.md create mode 100644 .claude/skills/logging/agents/openai.yaml create mode 100644 .claude/skills/logging/references/facade-and-records.md create mode 100644 .claude/skills/logging/references/platform-transports.md create mode 100644 .claude/skills/logging/references/proven-platform-patterns.md create mode 100644 .claude/skills/logging/references/trace-origins.md create mode 100644 .claude/skills/native-integration/SKILL.md create mode 100644 .claude/skills/routing/SKILL.md create mode 100644 .claude/skills/routing/agents/openai.yaml create mode 100644 .claude/skills/routing/references/tanstack-router.md create mode 100644 .claude/skills/state-management/SKILL.md create mode 100644 .claude/skills/state-management/references/ownership-and-scope.md create mode 100644 .claude/skills/state-management/references/persistence-and-security.md create mode 100644 .claude/skills/state-management/references/processes-and-coordination.md create mode 100644 .claude/skills/state-management/references/store-patterns.md create mode 100644 .claude/skills/user-feedback/SKILL.md create mode 100644 .claude/skills/user-feedback/agents/openai.yaml create mode 100644 .claude/skills/user-feedback/references/presentation-patterns.md diff --git a/.agents/skills/api-integration/SKILL.md b/.agents/skills/api-integration/SKILL.md new file mode 100644 index 0000000..b619cbf --- /dev/null +++ b/.agents/skills/api-integration/SKILL.md @@ -0,0 +1,376 @@ +--- +name: api-integration +description: Use whenever client code integrates with any external data source or API, including REST, GraphQL, RPC, IPC or native bridges, SDKs, gateways, microservices, WebSockets, generated contracts, and mock or fixture data used to scaffold application data. Apply when creating or changing API clients, transport or auth middleware, api/modules boundaries, contract generation or inferred types, request functions, query keys, query or mutation registries, pagination, query balancing with TanStack Pacer, batching, cache updates or invalidation, and components or hooks that consume API data. +--- + +# API Integration + +Treat `api/` as the client application's boundary with external sources. Keep +transport details, external contracts, server-state identities, and cache +effects behind that boundary so feature code consumes a stable, typed module +instead of speaking directly to a transport. + +The architecture is independent of the transport: + +```text +external contract authority + -> generated or inferred contract + -> typed source client + -> source module operations + -> key registry + -> query and mutation registries + -> application consumers +``` + +OpenAPI, GraphQL code generation, a schema registry, a typed SDK, and a native +RPC bridge are different ways to supply the first three links. They do not +change the remaining shape. + +## Start by finding the real source + +Before editing: + +1. Identify every external source touched by the change. +2. Find the authoritative contract for each source. +3. Find how its client is constructed and where cross-cutting behavior lives. +4. Trace the existing key, query, mutation, and invalidation paths. +5. Check how generated artifacts are produced and validated. + +Do not invent a second integration path because a call is small. Extend the +source module that already owns the contract and transport. + +## Place integrations under `api/` + +Prefer this application boundary: + +```text +api/ + client.ts + provider.tsx + modules/ + auth/ + service-a/ + service-b/ +``` + +- Put client-side integrations with external sources under `api/`. +- When the application has multiple sources, give each source its own + `api/modules/<source>/` directory. +- A source may be a separately governed service, a gateway-backed service + surface, a third-party SDK, or a native capability. The network address is + not the boundary: multiple services behind one gateway remain separate + modules when their contracts and ownership are separate. +- Name modules after the external source or contract authority, not after a + screen, component, or user journey. +- Split a large source module internally by operation or resource when useful, + but keep one public module surface. +- Keep source-neutral infrastructure such as the shared cache client and its + provider at the `api/` root. + +Feature components must not import generated transports, raw SDKs, RPC clients, +or wire contracts directly. They consume the source module. + +## Derive types; do not curate DTOs + +Use contract types in this order: + +1. Types generated from the authoritative external contract. +2. Types inferred from runtime schemas or a typed registry. +3. Request and response types exposed by a typed SDK or RPC client. +4. Narrow aliases or projections derived from one of the above. +5. Hand-written boundary types only when no machine-readable or typed contract + exists. + +Never duplicate an external DTO by manually restating its fields. Regenerate or +re-infer it. + +Do not create a hand-maintained `types.ts` dumping ground. A `types.ts` file +written by a generator is a generated artifact, not a dumping ground: mark it +as generated, never edit it manually, and make the generator its source of +truth. Genuine client-only models should be: + +- inferred from a runtime schema when validation is required; +- colocated with the operation that owns them; +- named after their responsibility rather than placed in a generic type bag; +- kept outside the API contract when they are view, form, or component state. + +An alias is useful when it creates a stable public name for an unreadable +generated lookup. An alias that merely duplicates another local alias is not. + +## Keep mock data as scaffolding + +Treat mock data as temporary display scaffolding, not as a half-built domain +layer. Keep mock-only data behind one obvious nearest boundary: prefer the +project's established API mocking or fixture boundary; otherwise colocate it +with the source module or consumer that owns it. + +- Store literal display text and values directly in the fixture. +- Reuse real generated, inferred, SDK, or package contracts when the represented + records already exist. +- Do not invent exported domain types, fake service methods, resolvers, + registries, adapters, or parser-style utilities for functionality that does + not exist. +- Do not normalize or transform static fixture data merely to imitate a future + production integration. +- Remove or replace the mock boundary when the real source becomes available; + do not let both become competing authorities. + +## Make generation reproducible + +When contract generation is available: + +- keep the upstream contract or a deterministic contract-sync command; +- map one independently governed source to one module output; +- generate into a predictable, reviewable location; +- include a generated-file header; +- fail when required contract inputs are missing; +- detect stale outputs after sources are renamed or removed; +- expose one documented command that can be rerun locally and in CI; +- validate that regeneration leaves the worktree unchanged. + +Do not edit generated output to make a consumer compile. Fix the authority, +generator, or adapter. + +## Construct clients at the boundary + +Put shared transport concerns in an API client factory or a source-specific +client: + +- base URL or channel selection; +- authentication and refresh coordination; +- protocol-required request and response payload encoding; +- retry and timeout policy; +- transport-level logging; +- middleware or interceptors. + +Create a dedicated client when a source needs different middleware, such as an +authentication endpoint that must not invoke its own refresh interceptor. + +Do not put query keys, cache invalidation, navigation, toasts, or component +state in the transport client. The client moves typed messages; the source +module owns server-state semantics. + +## Give every cached source a stable public module surface + +For a TanStack Query integration, prefer this top-to-bottom order: + +1. Intentional public aliases derived from the contract, if needed. +2. The typed source client. +3. Plain transport operations, when reuse or focused testing warrants them. +4. `<source>Keys`. +5. `<source>Queries`. +6. `<source>Mutations`. +7. Source-local helpers. + +The registries are plain values and factories outside React. Components choose +`useQuery`, `useSuspenseQuery`, `useMutation`, prefetching, or route loading; +the API module supplies the canonical options. + +If the repository uses another server-state library, preserve the same +separation: stable identities, reusable read definitions, reusable write +definitions, and explicit post-write cache effects. + +## Build a hierarchical key registry + +Export one key registry per source module. + +- Start with an immutable `all` namespace key. +- Derive narrower keys by spreading their parent key. +- Include every argument that changes the response. +- Put stable labels before dynamic values. +- Keep key values serializable and deterministic. +- Use the same registry for queries, mutations, prefetching, cache writes, and + invalidation. +- Design useful prefixes for broad invalidation, list invalidation, and exact + entity invalidation. + +There is no arbitrary maximum key depth. The hierarchy should describe cache +identity and invalidation boundaries, not satisfy a visual limit. + +```ts +export const accountsKeys = { + all: ["accounts"] as const, + lists: () => [...accountsKeys.all, "list"] as const, + list: (filters: AccountFilters) => [...accountsKeys.lists(), filters] as const, + details: () => [...accountsKeys.all, "detail"] as const, + detail: (id: string) => [...accountsKeys.details(), id] as const, + mutations: () => [...accountsKeys.all, "mutation"] as const, +}; +``` + +## Export query definitions as a registry + +Query factories own the canonical relationship between a key and its fetch: + +```ts +export const accountsQueries = { + list: (filters: AccountFilters) => + queryOptions({ + queryKey: accountsKeys.list(filters), + queryFn: () => listAccounts(filters), + }), + detail: (id: string) => + queryOptions({ + queryKey: accountsKeys.detail(id), + enabled: Boolean(id), + queryFn: () => getAccount(id), + }), +}; +``` + +- Use `queryOptions` or the repository's equivalent so consumers share one + definition. +- Gate a query when required identity is unavailable. +- Keep UI effects out of query functions. +- Return contract data or an intentional boundary normalization, not a + component-shaped view model. +- For pagination, make the cursor or offset part of the query definition and + implement a real exhaustion condition. + +Plain transport functions are optional. Keep them when multiple query +definitions reuse an operation, non-React code calls it, or focused tests need +the transport boundary. Otherwise a typed client call may live directly in the +query function. + +## Export mutation definitions and declare cache effects + +Define reusable mutation option factories outside React: + +```ts +export const accountsMutations = { + rename: () => + mutationOptions({ + mutationKey: [...accountsKeys.mutations(), "rename"] as const, + mutationFn: renameAccount, + onSettled: (_data, _error, variables) => { + void queryClient.invalidateQueries({ queryKey: accountsKeys.lists() }); + void queryClient.invalidateQueries({ + queryKey: accountsKeys.detail(variables.id), + }); + }, + }), +}; +``` + +Every write must make its cache effects explicit: + +- invalidate the exact entity when only that entity can change; +- invalidate a list prefix when membership or ordering can change; +- invalidate the source root only when the effect is genuinely broad; +- invalidate other source modules when the write changes their data; +- update cache directly only when the returned contract data is sufficient to + do so without guessing; +- state intentionally when a mutation has no cached read effect. + +Prefer `onSettled` when the server may have changed state despite an error or +when this is the repository convention. Use `onSuccess` when the protocol +guarantees no state change on failure and avoiding a failed-write refetch is +important. Correctness determines the callback, not habit. + +Consumers own UI effects such as navigation, notifications, dialog state, and +form reset. They must not replace the module's cache lifecycle accidentally. +Prefer per-call callbacks or explicitly compose handlers when adding local +behavior. + +## Preserve transport errors + +Propagate the transport's original error value unchanged by default. Prefer the +transport's own error primitives and handling mechanisms, such as an +`AxiosError`, a Fetch `Response` or rejection, or an SDK/RPC error type. + +Do not serialize, deserialize, normalize, wrap, map, clone, or reconstruct an +error merely to create a uniform API shape. In particular: + +- do not replace a transport error with a generic `Error`; +- do not invent `ApiError`, `toApiError`, error-code maps, registries, or + conversion helpers; +- do not discard the original prototype, status, headers, body, code, cause, + retry metadata, or transport-specific guards; +- do not mutate errors while logging them; +- when a protocol returns a typed failure value rather than throwing, propagate + it as-is; throw that value directly when the query or mutation boundary + requires a rejection. + +If the project already has a specialized error registry, adapter, serializer, +or application-wide error flow, use it exactly as designed. Verify that it is +the established integration path before relying on it. Do not create or extend +such an abstraction just for the current API integration unless the task +explicitly requires changing the project's error architecture. + +Keep presentation outside the API module: components or the existing +application error flow own translated messages, toasts, and recovery UX. + +## Balance compatible queries through one batch client + +When many independent query functions target the same batch-capable source +operation, place one source-owned query balancer between them and the transport. +Let TanStack Query continue to own caching and identical-key deduplication. Let +TanStack Pacer collect distinct, temporally adjacent requests and flush them as +one transport batch. + +- Create one batcher per compatible source operation and auth, tenant, locale, + or routing context. Never choose transport context from the first queued item. +- Let every enqueue return its own promise. Preserve its `resolve` and `reject` + callbacks until the batch result is routed back to that caller. +- Flush after a short coalescing delay or when `maxSize` is reached. Treat + `maxSize` as a batch trigger, not as queue-capacity rejection. +- Route responses by array position only when the transport contract guarantees + stable ordering. Otherwise correlate them by an existing request identifier. +- Reject every affected caller with the original batch-level transport error. + Deliver per-item failure values to their matching callers unchanged. +- Keep results in TanStack Query's cache, not in the batcher. +- Avoid priority reordering unless correlation is explicit. +- Do not connect one query consumer's abort signal to a shared batch abort. +- Do not enable retries for writes or non-idempotent reads without an explicit + source guarantee. +- Expose `flush` only when a latency-sensitive caller genuinely needs it. + +Use atomic or manually constructed write batches only when the source contract +defines their ordering, partial-failure, and idempotency semantics. Invalidate +all affected cache prefixes after a successful or potentially applied write. + +Read `references/query-balancing.md` before implementing Pacer-based batching. +It contains a thin generic implementation and the lifecycle invariants that +prevent lost, crossed, or permanently pending query promises. + +## Verification + +Before finishing an API change, verify: + +- external calls remain behind `api/`; +- each source has one clear module owner; +- generated artifacts reproduce from their authority; +- no external DTO was manually duplicated; +- no hand-maintained generic `types.ts` was introduced; +- mock-only data stays behind one explicit boundary and does not introduce a + parallel domain model or fake integration layer; +- keys contain every response-changing input; +- query and mutation definitions are reusable outside components; +- each mutation's cache effects are explicit and tested; +- cross-source invalidation is covered; +- query batchers preserve one-to-one result routing and leave no caller promise + pending after success, failure, flush, or exposed cancellation; +- transport errors remain unchanged unless an existing project-wide error flow + explicitly owns their conversion; +- no new error wrapper, converter, serializer, or registry was invented; +- auth, retry, and error behavior is tested at the client boundary; +- typecheck, focused tests, and contract-generation drift checks pass. + +## References + +- Read `references/module-pattern.md` for a neutral source-module skeleton. +- Read `references/query-balancing.md` when multiple query functions can share + a source batch operation or when using TanStack Pacer. +- Read `references/transport-examples.md` when choosing how generated, + schema-inferred, SDK, or RPC contracts feed the same module architecture. + +## Related skills + +- Client state that does not mirror an external source: `state-management`. +- Query or mutation failure propagation, reporting, retry, and duplicate + suppression: `error-handling`. The no-invention rule above remains binding. +- User-visible query and mutation outcomes: `user-feedback`. +- Wiring mutations to form submission: `forms`. +- Broader source-tree ownership decisions: `file-structure`. +- Rendering loading, error, empty, and data states from an API query: + `components`. diff --git a/.agents/skills/api-integration/references/module-pattern.md b/.agents/skills/api-integration/references/module-pattern.md new file mode 100644 index 0000000..a3c9e48 --- /dev/null +++ b/.agents/skills/api-integration/references/module-pattern.md @@ -0,0 +1,157 @@ +# Source Module Pattern + +This example uses TanStack Query because it makes the registries concrete. +Replace the transport and cache adapter without changing the ownership model. + +## Suggested layout + +```text +api/ + client.ts + provider.tsx + modules/ + accounts/ + index.ts + source.ts + contract.generated.ts +``` + +`source.ts` adapts the repository's typed transport. `contract.generated.ts` is +optional: it may instead live in a generated package or be unnecessary when a +typed SDK, schema registry, or RPC client already exposes the contract. + +## Shared API infrastructure + +```ts +// api/client.ts +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient(); +``` + +```tsx +// api/provider.tsx +import { QueryClientProvider } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; + +import { queryClient } from "./client"; + +export function APIProvider({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; +} +``` + +## Source module + +```ts +// api/modules/accounts/index.ts +import { + infiniteQueryOptions, + mutationOptions, + queryOptions, +} from "@tanstack/react-query"; + +import { queryClient } from "@/api/client"; + +import { + accountsSource, + type AccountFilters, + type RenameAccountRequest, +} from "./source"; + +export const accountsKeys = { + all: ["accounts"] as const, + lists: () => [...accountsKeys.all, "list"] as const, + list: (filters: AccountFilters) => [...accountsKeys.lists(), filters] as const, + infinite: (filters: AccountFilters) => + [...accountsKeys.lists(), "infinite", filters] as const, + details: () => [...accountsKeys.all, "detail"] as const, + detail: (id: string) => [...accountsKeys.details(), id] as const, + mutations: () => [...accountsKeys.all, "mutation"] as const, +}; + +export async function listAccounts(filters: AccountFilters) { + return accountsSource.list({ filters }); +} + +export async function getAccount(id: string) { + return accountsSource.get({ id }); +} + +export async function renameAccount(variables: { + id: string; + request: RenameAccountRequest; +}) { + return accountsSource.rename(variables); +} + +export const accountsQueries = { + list: (filters: AccountFilters) => + queryOptions({ + queryKey: accountsKeys.list(filters), + queryFn: () => listAccounts(filters), + }), + infinite: (filters: AccountFilters) => + infiniteQueryOptions({ + queryKey: accountsKeys.infinite(filters), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }) => + listAccounts({ + ...filters, + cursor: pageParam, + }), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }), + detail: (id: string) => + queryOptions({ + queryKey: accountsKeys.detail(id), + enabled: Boolean(id), + queryFn: () => getAccount(id), + }), +}; + +export const accountsMutations = { + rename: () => + mutationOptions({ + mutationKey: [...accountsKeys.mutations(), "rename"] as const, + mutationFn: renameAccount, + onSettled: (_data, _error, variables) => { + void queryClient.invalidateQueries({ queryKey: accountsKeys.lists() }); + void queryClient.invalidateQueries({ + queryKey: accountsKeys.detail(variables.id), + }); + }, + }), +}; +``` + +The source adapter, contract types, keys, operations, and cache definitions form +one reviewable integration boundary. A large module may split these into named +files without changing its public surface. + +`source.ts` may wrap generated HTTP code, a schema-inferred command transport, +or a typed RPC/SDK client. It propagates transport errors unchanged unless the +project already has a specialized error flow that owns their conversion. See +`transport-examples.md`; no variant is the default. + +## Consumer + +```tsx +const account = useQuery(accountsQueries.detail(accountId)); +const renameAccount = useMutation(accountsMutations.rename()); + +function submit(request: RenameAccountRequest) { + renameAccount.mutate( + { id: accountId, request }, + { + onSuccess: () => { + showSuccess("Saved"); + closeDialog(); + }, + }, + ); +} +``` + +The per-call callback adds UI behavior without replacing the mutation +registry's cache lifecycle. diff --git a/.agents/skills/api-integration/references/query-balancing.md b/.agents/skills/api-integration/references/query-balancing.md new file mode 100644 index 0000000..566703e --- /dev/null +++ b/.agents/skills/api-integration/references/query-balancing.md @@ -0,0 +1,245 @@ +# Query Balancing with TanStack Pacer + +Use a query balancer when many independent callers request different data from +the same batch-capable source operation. The balancer coalesces nearby requests, +executes one transport call, and routes each result back to the promise created +for its original caller. + +## Contents + +- Purpose and ownership +- Minimal implementation +- Source-module integration +- Contract and lifecycle rules +- Verification + +## Purpose and ownership + +This is temporal request balancing, not server load balancing: + +```text +query A ─┐ ┌─ result A -> query A +query B ─┼─ short queue ─ batch call ─ result B -> query B +query C ─┘ └─ result C -> query C +``` + +TanStack Query and TanStack Pacer have different jobs: + +- TanStack Query caches results, deduplicates identical query keys, and manages + query lifecycle. +- TanStack Pacer groups distinct requests that arrive close together and + controls when the group executes. +- The source module owns the batch endpoint and the one-to-one mapping between + queued requests and returned results. + +Keep one long-lived balancer per batch-capable source operation. Do not create a +batcher per component or per query invocation. + +## Minimal implementation + +Use the core Pacer class for a non-React service. If the repository exposes the +same class through its framework package, follow the established import. + +```ts +import { AsyncBatcher } from "@tanstack/pacer"; + +type PendingRequest<TRequest, TResponse> = { + request: TRequest; + resolve: (response: TResponse) => void; + reject: (reason: unknown) => void; +}; + +type QueryBatcherOptions<TRequest, TResponse> = { + key: string; + maxSize: number; + wait: number; + execute: ( + requests: readonly TRequest[], + ) => Promise<readonly TResponse[]>; +}; + +export function createQueryBatcher<TRequest, TResponse>({ + key, + maxSize, + wait, + execute, +}: QueryBatcherOptions<TRequest, TResponse>) { + const batcher = new AsyncBatcher< + PendingRequest<TRequest, TResponse> + >( + async (pending) => { + const responses = await execute( + pending.map(({ request }) => request), + ); + + if (responses.length !== pending.length) { + throw new Error( + `Batch response count mismatch: expected ${pending.length}, received ${responses.length}`, + ); + } + + pending.forEach(({ resolve }, index) => { + resolve(responses[index]!); + }); + }, + { + key, + maxSize, + wait, + onError: (error, pending) => { + pending.forEach(({ reject }) => { + reject(error); + }); + }, + throwOnError: false, + }, + ); + + return { + request(request: TRequest): Promise<TResponse> { + return new Promise<TResponse>((resolve, reject) => { + void batcher.addItem({ request, resolve, reject }); + }); + }, + async flush(): Promise<void> { + await batcher.flush(); + }, + }; +} +``` + +The wrapper is intentionally thin: + +- Pacer owns collection, timing, `maxSize`, execution, and observable state. +- The wrapper owns only the caller promise and result demultiplexing. +- `throwOnError: false` prevents the ignored `addItem` promise from producing + an unhandled rejection; `onError` rejects the real caller promises with the + original error. +- The cardinality error represents a broken batch contract. It does not wrap or + convert a transport error. + +## Source-module integration + +Construct the balancer once inside the source module: + +```ts +type AccountRequest = { + id: string; +}; + +const accountDetailBatcher = createQueryBatcher< + AccountRequest, + AccountResponse +>({ + key: "accounts.detail", + maxSize: 50, + wait: 20, + execute: (requests) => accountsSource.getMany(requests), +}); + +export function getAccount(id: string) { + return accountDetailBatcher.request({ id }); +} + +export const accountsQueries = { + detail: (id: string) => + queryOptions({ + queryKey: accountsKeys.detail(id), + enabled: Boolean(id), + queryFn: () => getAccount(id), + }), +}; +``` + +Concurrent components, route loaders, and prefetches now use the same +`accountsQueries.detail` definition. They do not know whether their request was +sent alone or as part of a batch. + +If the transport returns a per-item failure envelope, deliver that envelope to +the matching caller unchanged. Let the source operation apply the project's +existing direct error handling. Do not make the generic balancer understand +domain statuses or invent a common error type. + +## Contract and lifecycle rules + +### Batch only compatible work + +All items in one batch must share: + +- source and batch operation; +- base URL or transport channel; +- authentication and tenant context; +- locale or headers that affect the response; +- retry, timeout, and consistency semantics. + +Close these dependencies over the batcher's `execute` function. Do not store a +client, session, processor, or options on every item and then use the first +item's values for the entire batch. + +### Preserve correlation + +Use positional routing only when the source guarantees response order. Do not +sort or reprioritize queued items in that case. + +When ordering is not guaranteed, send an existing correlation identifier and +route responses through a lookup: + +```ts +const pendingById = new Map( + pending.map((item) => [item.request.requestId, item]), +); + +for (const response of responses) { + pendingById.get(response.requestId)?.resolve(response); +} +``` + +Reject unmatched requests as a batch-contract violation. Never guess which +caller owns a response. + +### Preserve error identity + +- Reject every item with the original error when the entire batch call fails. +- Preserve each transport-provided per-item error or failure envelope. +- Do not serialize, normalize, or wrap errors in the balancer. +- Use an existing project-wide error flow only when the project already + requires it. + +### Keep cancellation collective + +An in-flight transport batch belongs to several callers. Aborting it because +one component unmounted would cancel unrelated queries. Do not wire an +individual query signal directly to the batcher's shared abort signal. + +If individual cancellation is required, mark or remove only that pending item +before execution and settle its promise with the original abort reason. If +exposing `cancel`, `clear`, or `reset`, settle every removed caller first; +otherwise their promises remain pending forever. + +### Retry only safe batches + +Pacer can retry a failed batch, but retrying repeats every item. Enable it only +when the whole operation is idempotent and the source defines safe retry +semantics. Keep retries off by default for mutations and mixed-effect batches. + +### Separate query balancing from write batching + +Query balancing is most useful for independent reads. A write batch has +additional atomicity, ordering, partial-success, idempotency, and invalidation +requirements. Do not send mutations through a query balancer merely because the +transport accepts an array. + +## Verification + +Test the balancer with deterministic test executors: + +- several requests inside the window produce one batch call; +- reaching `maxSize` flushes immediately; +- each caller receives the response at its own position or correlation id; +- a batch-level error rejects every caller with the same error object; +- a per-item failure reaches only its matching caller; +- a response-count or correlation mismatch settles every affected promise; +- a later batch cannot resolve promises from an earlier batch; +- explicit `flush` settles all currently queued requests; +- cancellation or clearing, when exposed, leaves no promise pending; +- retries occur only when the operation is explicitly idempotent. diff --git a/.agents/skills/api-integration/references/transport-examples.md b/.agents/skills/api-integration/references/transport-examples.md new file mode 100644 index 0000000..64442fd --- /dev/null +++ b/.agents/skills/api-integration/references/transport-examples.md @@ -0,0 +1,133 @@ +# Contract and Transport Variants + +These variants are peers. Select the one supported by the source authority; +none changes the source-module, key-registry, query-registry, mutation-registry, +or invalidation pattern. + +## Generated HTTP contract + +Use this when an OpenAPI or similar machine-readable contract is authoritative. + +```ts +import createClient from "openapi-fetch"; + +import type { components, paths } from "./contract.generated"; + +export type CreateAccountRequest = + components["schemas"]["CreateAccountRequest"]; + +const accountsClient = createClient<paths>({ baseUrl }); + +export async function createAccount(request: CreateAccountRequest) { + const { data, error } = await accountsClient.POST("/accounts", { + body: request, + }); + + if (error) { + throw error; + } + + return data; +} +``` + +The generator owns `contract.generated.ts`. The module may export readable +aliases derived from it, but must not restate DTO fields. + +The same approach applies to GraphQL, protobuf/gRPC, and other contract +generators: generate the client or types, then adapt them behind the source +module. + +## Runtime schema or registry + +Use this when the source publishes executable schemas or a typed command +registry. + +```ts +import type { resources } from "@vendor/source-registry"; +import type { z } from "zod"; + +type ListAccountsParams = z.infer< + typeof resources.accounts.list.request.shape.details +>; + +export async function listAccounts(params: ListAccountsParams) { + const response = await sendTypedCommand< + typeof resources.accounts.list + >({ + action: "get", + resource: "/accounts", + details: params, + }); + + if (response.status !== "success") { + throw response; + } + + return response; +} +``` + +Types are inferred from the registry that also validates runtime messages. +The typed failure envelope is propagated unchanged. Avoid both a parallel +hand-written DTO layer and an invented error-conversion layer. + +## Typed SDK or native RPC client + +Use this when the client method already carries request and response types. + +```ts +import { AccountsNativeModule } from "@internal/accounts/native/client"; +import { queryOptions } from "@tanstack/react-query"; + +export const accountsKeys = { + all: ["accounts"] as const, + detail: (id: string) => [...accountsKeys.all, "detail", id] as const, +}; + +export const accountsQueries = { + detail: (id: string) => + queryOptions({ + queryKey: accountsKeys.detail(id), + enabled: Boolean(id), + queryFn: () => AccountsNativeModule.getAccount({ id }), + }), +}; +``` + +No local request or response aliases are required when inference remains +readable at the call site. The typed RPC contract and client factory are the +type authority. + +## Dedicated unauthenticated client + +Some operations must not use normal source middleware. Authentication refresh +is the common example: + +```ts +const sourceClient = createAuthenticatedClient<paths>(); +const authClient = createBareClient<authPaths>(); +``` + +Keep this exception explicit inside the owning source module or client +infrastructure. Do not add component-level flags that disable middleware for +individual calls. + +## Contract-derived projection + +Client code sometimes needs a subset or combination of contract types. Derive +it instead of copying it: + +```ts +type SearchParams = NonNullable< + paths["/accounts"]["get"]["parameters"]["query"] +>; + +type AccountSummary = Pick< + components["schemas"]["Account"], + "id" | "display_name" +>; +``` + +If the shape exists only for a form or view, keep it with that form or view. +It is not part of the API contract. diff --git a/.agents/skills/code-quality/SKILL.md b/.agents/skills/code-quality/SKILL.md new file mode 100644 index 0000000..00bafaf --- /dev/null +++ b/.agents/skills/code-quality/SKILL.md @@ -0,0 +1,129 @@ +--- +name: code-quality +description: Use whenever cleaning, validating, or finishing code; fixing or preventing formatting, lint, typecheck, import-order, naming, or other repository-standard violations; deciding whether the formatter or linter should autofix mechanical issues instead of editing them by hand; or running verification commands before calling work complete. +--- + +# Code Quality + +Keep code mechanically clean through the repository's own type checker, linter, +formatter, and build tooling. Treat every rule here as a default unless the +repository's configuration says otherwise; its configuration is authoritative. + +## Verification (run before done) + +Always run the repo's typecheck, lint, format, and build before declaring a +change complete. The script runner is repo-specific; these are the SAME step +expressed two ways: + +```bash +# pnpm-based repo # bun-based repo +pnpm typecheck bun run typecheck +pnpm lint bun run lint +pnpm format bun run format:check # check; *:fix to auto-fix +pnpm build bun run build +``` + +- For mechanical formatting, import-order, and autofixable lint violations, run + the repository's configured formatter or lint-fix command before editing the + affected lines manually. +- Let the tool apply the rules it owns, then inspect its diff and fix only the + remaining semantic or non-autofixable violations by hand. +- Do not imitate formatter or linter output manually when the repository already + provides the command that produces it. +- Run from the repo's canonical directory (root or the app package — follow the + repo's scripts; some lint scripts run with `--fix --max-warnings=0`). +- Fix everything to zero warnings/errors. Do not leave a red checker. +- If the change touches native/main-process code or packaging, also run the + relevant native build/dev check — see **native-integration**. + +## TypeScript: strict and honest + +Keep strict mode on. Expect (and do not disable) at least: + +```jsonc +{ + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true +} +``` + +Typing principles: + +- Prefer `unknown` + narrowing over `any`. Narrow with `typeof`, `instanceof`, + or `in` guards before accessing properties. +- Use `enum` for named constant sets; reach for `as const` objects when an enum + is heavier than the context needs, then derive `type T = typeof X[keyof typeof X]`. +- Use `satisfies` to type-check a literal without widening its inferred type. +- Derive types from their contract source, not by hand-duplication: `z.infer` + from a Zod schema, or generated types from an OpenAPI generator — adapt to the + repo's stack (see **api-integration** for contract typing). +- Add explicit return types on exported functions. + +## Path aliases, never deep relatives + +Import through the repo's configured alias (`@/...`, `@config`, `@internal/...`). +Never reach across the tree with `../../../`. + +```ts +// Good // Bad +import { UiButton } from '@/ui/UiButton' import { UiButton } from '../../../ui/UiButton' +``` + +## Linting norms + +- No unused variables. Remove them, or prefix intentionally-unused params with + `_` (e.g. `(_event) => …`). +- No stray `console`. If a log is genuinely required, scope a single + `eslint-disable-next-line` — and prefer the repo's logger (see + **logging**). +- React Hooks rules apply; keep hook dependency lists correct. +- Let the linter sort/dedupe imports. Disable rules narrowly and only with a + reason, never blanket-disable a whole file casually. + +## Import order + +Group imports, blank-line-separated, in this order (the linter usually enforces +it): + +1. React +2. External packages +3. Internal alias imports (`@/...`) +4. Relative imports (`./...`) + +```tsx +import { useState } from 'react' + +import { useQuery } from '@tanstack/react-query' + +import { UiButton } from '@/ui/UiButton' + +import { Child } from './Child' +``` + +## Formatting + +Adopt the repo's formatter — follow its `.prettierrc` (or equivalent) rather than +re-styling by hand; let the formatter own whitespace, quotes, and class sorting. +One repo's baseline, as an example of the kind of conventions to honor: 2-space +indent, no semicolons, single quotes (incl. JSX), trailing commas everywhere, +`arrowParens: avoid`, and a Tailwind class-sorting plugin. Match whatever the +target repo configures; do not impose this baseline on a repo that disagrees. + +## Naming + +- Components: `PascalCase`. Hooks: `camelCase` with `use` prefix. +- Module-level constants: `SCREAMING_SNAKE_CASE`. +- Files: `PascalCase` for components, `camelCase` for utilities. +- CSS: kebab-case utility classes (via Tailwind). The literal-class / `cn()` + rule lives in **components**. + +## Cross-links + +- React component behavior, effect alternatives, and internal ordering — + **components**. +- Logger instead of `console` — **logging**. +- Native/main-process build checks — **native-integration**. diff --git a/.agents/skills/components/SKILL.md b/.agents/skills/components/SKILL.md new file mode 100644 index 0000000..9ed3a17 --- /dev/null +++ b/.agents/skills/components/SKILL.md @@ -0,0 +1,412 @@ +--- +name: components +description: Use whenever work creates, modifies, refactors, reviews, styles, composes, or places any React component or conditional JSX; selects or adds a Ui* primitive; ports shadcn/Base UI components; designs compound, polymorphic, Context, or controlled/uncontrolled APIs; handles parent-child layout ownership, hooks and effects, skeletons, loading/error/empty/data states, or render-prop data loaders. Covers UI primitives, common business components, and feature- or page-owned components. +--- + +# Components + +Treat the repository's existing `ui/` directory as the design-system authority. +Its `Ui*` components are local adaptations of official **shadcn** components built +on **Base UI** (`@base-ui/react`), commonly using +`class-variance-authority` (cva) and `cn()` (tailwind-merge). + +Treat shadcn as an upstream source of component code and documentation, not as a +registry-managed project structure. Preserve the local `Ui*` conventions instead +of introducing Radix, HeroUI, another primitive system, or a parallel feature-local +design system. + +## Distinguish Primitives From Product Components + +Reserve the `ui/` directory and `Ui*` prefix for reusable design primitives and +their stable specializations. + +Components that compose those primitives with product behavior are not `Ui*` +components. Name them after their business or interface responsibility and place +them at the nearest common owner that contains all of their real consumers: + +- one consumer → keep it inside that component or page boundary; +- several consumers inside one page subtree → move it only to their nearest + shared owner inside that page; +- several pages or subfeatures inside one feature → move it to that feature's + contextual `components/` boundary; +- consumers from genuinely different features or application areas → place it + in the shared business-component boundary, usually `common/`. + +Do not promote a component based on hypothetical reuse, import count, or reuse +within a single owner. Move it upward only as real consumers cross ownership +boundaries: + +```text +page/components + ↑ +resource-or-feature/components + ↑ +common +``` + +In the usual application layout, `common/` sits beside `ui/`: + +```text +src/ +├── ui/ # Ui* design primitives +└── common/ # Shared product and business components +``` + +Components in `common/` use names that describe their product or business +responsibility; the `Ui*` prefix remains reserved for primitives. Treat +`common/` as a real owner, not as a catch-all. Put only components with +demonstrated use across genuinely different ownership areas there, keep +feature- or page-private components local, and organize complex common +components recursively by responsibility. + +Follow **file-structure** for the exact directory. A private child belongs inside +its owning component or page, commonly under its contextual `components/` +folder. Do not move a component into `ui/` merely because it is reusable, +complex, or implemented as a compound component. + +The component-design rules below are not limited to `Ui*` primitives. Apply +composition, Context, controlled/uncontrolled state, effect, ownership, and +render-state guidance to product components whenever those patterns make the +component simpler and more coherent. + +## Inspect The Existing UI System First + +Before implementing or styling any component: + +1. Decide whether the responsibility is a design primitive or a product + component, then inspect the nearest current owner. +2. Locate the repository's `ui/` directory from existing imports, aliases, and + source structure so the component reuses the available primitives. +3. Inspect its filenames and search for the needed behavior, not only the exact + name you expect. +4. Read the closest existing `Ui*` primitive, its named variations, and a few + real consumers. Read stories when the repository has them. +5. Reuse the existing primitive or variation when it already expresses the + required semantics and state. +6. Add a shared primitive only when the capability is genuinely missing. + +Do not enumerate the available primitives in this skill; discover the target +repository's current UI surface every time. Do not hand-roll or restyle an +interactive control before checking whether its `Ui*` equivalent already exists. + +## Port Missing Primitives From Official Sources + +When the required primitive is absent: + +1. Read the current official shadcn documentation and the matching Base UI API. +2. Select the Base UI implementation, not a Radix or another-library variant. +3. Obtain the official source manually. Use the shadcn CLI only as an optional + way to inspect or download source; do not let it initialize, restructure, or + overwrite the application's `ui/` directory. +4. Inspect analogous local primitives to learn import paths, tokens, formatting, + exports, and file shape. +5. Port the source into the local `Ui*` layer and adapt only its integration: + local `cn`, theme tokens, icon system, `data-slot` values, import aliases, and + `Ui*` exports. +6. Preserve the upstream semantics, accessibility behavior, keyboard behavior, + state attributes, prop forwarding, and ref contract. +7. Add stories or focused tests when that is the repository's established + practice, then run the normal verification through **code-quality**. + +Never paste an upstream component directly into a feature. The local `Ui*` +adaptation becomes the reusable boundary. + +## Ui* Wrapper Convention + +A `Ui*` module adapts one or more shadcn/Base UI parts into the application's +reusable design primitive. + +- Keep internal component names aligned with upstream, then alias every public + export with the `Ui` prefix: + `export { Button as UiButton, buttonVariants as UiButtonVariants }`. +- For multi-part primitives, preserve the parts and alias each public part: + `DialogContent as UiDialogContent`, `DialogTrigger as UiDialogTrigger`, and so + on. +- Put stable `data-slot` attributes on rendered parts. Preserve upstream props + and refs instead of narrowing the primitive accidentally. +- Use cva for real variant axes and compose caller classes through `cn(...)`. +- Prefer an existing wrapper before writing a new one. Add a new `Ui*` only for a + genuinely reusable design primitive — never for one feature's one-off layout. +- Use the icon system the repo already established; do not introduce another. + +## Keep Styling At The Owning Layer + +The shared `Ui*` layer owns the visual language of controls and surfaces: +colors, typography treatments, backgrounds, borders, radii, shadows, and +hover/focus/active/disabled/invalid/ARIA state styling. + +Feature and page components should use Tailwind primarily for structure: +layout, positioning, sizing, spacing, responsive arrangement, and placement of +children. Passing `className` for those structural concerns is expected. + +Do not rebuild a primitive's visual or interaction states in a consumer. If a +visual treatment is intentional and reusable, add a clearly named variation +beside the primitive. If it is unique feature composition rather than a reusable +primitive treatment, compose existing `Ui*` parts without redefining their +owned states. + +## File & Folder Layout — base + variations + +Choose one of three shapes from the component's actual responsibility: + +**1. Self-contained primitive → a flat file** `Ui{Name}.tsx` at the ui root +(`UiCard.tsx`, `UiAlert.tsx`, `UiDialog.tsx`). It may export one component or +several upstream compound parts. + +**2. Primitive with specializations → a folder `Ui{Name}/`** named after the +component and containing: + +- **`base.tsx`** — the canonical local adaptation of the official component. It + contains the shared primitive behavior, state styles, and cva variants and + exports `Ui{Name}` plus any public variants helper. +- **Named visual variations beside `base.tsx`** — one file per stable treatment, + kebab-case named for what it is (`outline-primary.tsx`, + `ghost-destructive.tsx`, `outline.tsx`, `simple.tsx`). Each one: + - imports the original: `import { UiButton } from './base'` + - is a **`default export`** named `Ui{Name}{Variation}` (`UiButtonOutlinePrimary`, + `UiInputOutline`) + - locks a base variant and/or layers extra classes via `cn('…extra', className)`, + narrowing the props it now fixes (`Omit<ComponentProps<typeof UiButton>, 'variant'>`) + - or composes richer stable structure around `base`, such as affixes, field + chrome, or another repeated integration. +- No barrel — import the exact file: `@/ui/UiButton/base`, `@/ui/UiButton/outline-primary`. + +```tsx +// UiButton/outline-primary.tsx +import { UiButton } from './base' + +export default function UiButtonOutlinePrimary({ + className, + ...rest +}: Omit<ComponentProps<typeof UiButton>, 'variant'>) { + return ( + <UiButton + {...rest} + variant='outline' + className={cn('border-primary! text-primary bg-transparent!', className)} + /> + ) +} +``` + +**3. Bespoke/composed component (not a variant family) → `Ui{Name}/index.tsx`** +(+ assets or `components/` subparts): `UiIcon`, `UiMarkdown` (+ `styles.scss`), +`UiToaster` (+ `components/`). + +**Rule of thumb:** a recurring restyle of a `Ui*` belongs in a **named variation +file** under the component folder — not scattered as inline `className` overrides +across features. `className` does merge through `cn()` (caller overrides win), but +promote anything reused into a variation so the base stays the shared authority. + +### Keep the base free of feature-specific changes + +Adapt `base.tsx` intentionally when importing, updating, or correcting the shared +primitive itself. Do not edit it to satisfy one feature's visual request. Build +that treatment around the base through a sibling variation or composition. + +## Polymorphism (render prop, not Slot) + +Base UI primitives are polymorphic via the `render` prop / `useRender` hook + +`mergeProps` — there is no Radix `asChild`/`Slot` here. To let a caller swap the +rendered element, forward `render` or drive it with `useRender`. Ensure custom +render targets accept the forwarded ref and spread the received props onto their +underlying DOM element. Preserve correct element semantics; polymorphism is not +permission to make a button behave as a link or vice versa. + +## Compound Components + +Use the compound pattern when a primitive or business component has meaningful +parts that should compose independently while sharing one coherent behavior. +This includes shadcn-style parts such as `Select.Trigger`/`Select.Content` and +higher-level feature components decomposed into Root, Trigger, Content, +Indicator, or similar roles. + +Compound parts do not require Context by default. Use the local Context boundary +below only when the parts genuinely need shared state or behavior. + +## Context As A Local Composition Boundary + +Use Context when one coherent component, feature, or page subtree has several +parts that need the same state, derived values, or actions, and explicit props +would cause prop drilling or fragmented ownership. + +Place the Provider at the narrowest owner that contains all real consumers. +Keep state and actions in that owner and expose them through a guarded consumer +hook. Derive the context value type from its owning value hook or factory when +practical instead of declaring a parallel type manually. + +```tsx +const XContext = createContext<XState | null>(null) +const useXContext = () => { + const ctx = useContext(XContext) + if (!ctx) throw new Error('X parts must be used within X') + return ctx +} +``` + +Do not introduce Context when local state and explicit props remain clearer. Do +not use it to duplicate server state owned by the data-fetching layer or durable +client state owned by the state-management layer. + +## Controlled / Uncontrolled + +Choose the state contract that fits the component. Do not require every +component to support both modes. + +- Use an uncontrolled mode when the component can own its interaction state. +- Use a controlled mode when a parent must own that state. +- Support both when the same reusable component genuinely needs autonomous and + parent-driven usage. Accept `defaultX` for the uncontrolled seed and + `x` + `onXChange` for controlled usage; never switch modes after mount. +- Use `x !== undefined` only when `undefined` unambiguously means uncontrolled. + If `undefined` is a valid controlled value, define an explicit contract instead. + +## Avoid useEffect + +Treat `useEffect` as exceptional. Before adding one, prove that the component +must synchronize with a lifecycle that no existing declarative abstraction +already owns. The mere presence of an external source does not justify an +effect: when the source fits an async or server-state model, prefer the +repository's data-fetching layer, such as TanStack Query, so request lifecycle, +caching, retries, deduplication, and cancellation stay outside the component. + +Use an effect only as the final option for synchronization that genuinely +belongs to the component, such as an imperative browser API, a third-party +imperative library, or a subscription that cannot use `useSyncExternalStore`. +Do not use it as a general-purpose way to run component logic. + +| Instead of `useEffect` for… | Prefer | +| --- | --- | +| Fetching data | The data-fetching layer, such as TanStack Query — see **api-integration** | +| Derived or computed state | Compute during render, or use `useMemo` for expensive computation | +| Subscribing to an external store | `useSyncExternalStore` or store selectors — see **state-management** | +| Responding to a prop change | Compute during render or lift state up | +| Resetting state on prop change | A `key` prop that remounts the owned subtree | +| Handling a user action | The event handler itself | + +If a React data-flow or event-driven pattern expresses the behavior, use it +instead of adding an effect. + +## Parent Owns Placement + +A child owns its internal structure, content, internal visual treatment, and +local show/hide behavior. It must NOT own the styles that place it within its +parent: surrounding layout, positioning, sibling-dependent sizing, route-level +offsets, or dock dimensions. Apply those external layout styles through a parent +wrapper so the child renders correctly in any correctly-sized slot. + +This rule governs runtime layout and styling ownership, not source-file +placement. Locate the component itself according to **file-structure**. + +### Docked / sheet surfaces — two-layer split + +1. A **generic container** (bar/sheet) owns only open/close behavior and a content slot. +2. The **owning page or layout** applies absolute/flex/grid placement and + surrounding offsets through its wrapper. +3. The **business content** fills the provided slot without knowing the page, + shell, sibling panels, or external geometry. + +The container never decides its own position in the page. + +## Component internal ordering + +Order a component file as imports → types/interfaces → component. Inside the +component, call hooks before any conditional return, then keep computed values, +handlers, and render: + +```tsx +export function MyComponent({ title }: Props) { + const [open, setOpen] = useState(false) + const items = useMemo(() => data?.filter(item => item.active), [data]) + const handleClick = () => setOpen(true) + + return <UiButton onClick={handleClick}>{title}</UiButton> +} +``` + +## Skeletons + +A skeleton must preserve the real content's visible geometry: the same occupied +space, primary dimensions, and row/section arrangement. It does not need to copy +the real DOM or every decorative detail, but swapping in data must not cause a +layout shift. For lists, render several skeleton rows to represent a realistically +populated list. + +## Ordered Async-State Rendering + +For mutually exclusive async states in one UI region, use one ordered waterfall: +loading → error → empty → data. Base blocking loading and error states on the +absence of usable data, not on query flags alone. Existing cached or stale data +normally remains the data state during a background refetch or background error; +surface secondary status separately when the product requires it. + +Prefer an inline IIFE with sequential early returns. Avoid ternary expressions +for conditional rendering in JSX; never build nested or chained render +ternaries. They quickly obscure branch priority and turn JSX into an unreadable +conditional tree. Do not scatter conditionals that can overlap or render +contradictory states. + +```tsx +{(() => { + const hasUsableData = query.data !== undefined + + if (!hasUsableData && query.isPending) return <ListSkeleton /> + if (!hasUsableData && query.isError) return <ErrorState /> + if (!query.data?.items.length) return <EmptyState /> + return <List items={query.data.items} /> +})()} +``` + +Render shared chrome such as the header, title, and primary action once outside +the waterfall. Give separate regions separate waterfalls when they can load or +fail independently. + +## Render-Prop Data-Loading Components (abstract-requesting) + +Consider a thin render-prop requesting component when referenced entities are +loaded repeatedly across UI contexts or direct request wiring would clutter +their consumers. Let it accept the entity identity, call the existing query +options through the shared data-fetching client, and expose the query result to +the render prop. Keep loading, error, empty, and presentation decisions inline +at the call site: + +```tsx +<EntityById id={id}> + {({ data, isPending, isError }) => { + if (data === undefined && isPending) return <UiSkeleton /> + if (data === undefined && isError) { + return <UiAlert>Unable to load</UiAlert> + } + if (data === undefined) return null + return <EntityView entity={data} /> + }} +</EntityById> +``` + +Do not create one reflexively for every entity or embed presentation policy +inside it. Place it at the nearest common owner of its real consumers according +to **file-structure**. + +The render-prop boundary improves declarative composition, not network +performance by itself. Query caching, deduplication, key factories, batching, +and request balancing remain responsibilities of the shared data layer → see +**api-integration**. + +## Cross-References + +- Route/page contracts, layouts, params, and navigation → **routing**. +- Physical file placement, visibility, and ownership boundaries → + **file-structure**. +- Form state, `register`, `Controller`, and validation → **forms**. +- Query and mutation options, keys, cache updates, invalidation, batching, and + request balancing → **api-integration**. +- Durable client state, selectors, persistence, and external-store + subscriptions → **state-management**. +- User-facing labels, placeholders, accessibility text, and messages when the + application uses i18n → **localization**. +- Error propagation, reporting, retry, and recovery boundaries → + **error-handling**. +- Error, success, warning, fallback, and recovery presentation → + **user-feedback**. +- Structured diagnostics and transports → **logging**. +- Lint, formatting, typecheck, and build verification → **code-quality**. diff --git a/.agents/skills/error-handling/SKILL.md b/.agents/skills/error-handling/SKILL.md new file mode 100644 index 0000000..3e2282f --- /dev/null +++ b/.agents/skills/error-handling/SKILL.md @@ -0,0 +1,251 @@ +--- +name: error-handling +description: Use whenever work adds, changes, reviews, or debugs thrown errors, rejected promises, catch or finally blocks, retries, cancellation, fallbacks, recovery, error stacks, cause chains, lost call sites, async or RPC origins, source maps, global error listeners, React or route error boundaries, TanStack Query failure policy, incident reporting, expected domain outcomes, or decides which layer owns a failure. Trigger even when the request only mentions an unhandled rejection, swallowed error, duplicate report, misleading stack trace, crash fallback, mutation failure, background refetch failure, Sentry or OpenReplay exception capture, or whether an error should propagate. +--- + +# Error Handling + +Handle a failure once, at the narrowest boundary that can make the required +decision. Keep propagation, reporting, recovery, and user presentation separate +so that one failure does not become several logs, incidents, and messages. + +Error handling is a control-flow concern. Logging records diagnostic facts; +user feedback presents an outcome. Neither one automatically means the failure +was handled. + +## Trace The Existing Flow First + +Before changing an error path: + +1. Find where the value originates and whether it is thrown, rejected, or + returned as a typed result. +2. Trace every layer that catches, logs, reports, retries, converts, or presents + it. +3. Inspect global listeners, error boundaries, and TanStack Query cache + callbacks that may already observe it. +4. Identify the boundary that owns recovery and the boundary that owns user + feedback. +5. Preserve the repository's established reporter and provider integrations. + +Do not add a local `catch` until this trace proves what decision belongs there. + +## Classify The Outcome Before Handling It + +Do not treat every non-happy path as the same failure: + +- a successful value needs no error path; +- an expected domain outcome should remain a typed result when the contract + models it as one; +- cancellation or superseded work usually needs cleanup, not an incident; +- recoverable degradation may justify a warning and fallback; +- an operation failure may need local recovery, reporting, or both; +- an unrecoverable render or initialization failure needs a recovery boundary. + +Do not invent an `Error` subclass merely to represent a non-failure such as +`pending`, `queued`, or `requiresApproval`. Prefer the generated or +schema-derived discriminated result. Preserve an existing typed-error control +flow when the project deliberately uses one, but do not generalize it into a +new application-wide convention. + +## Give Each Boundary One Job + +Low-level transports, API functions, and reusable utilities normally preserve +and propagate failures. They do not choose toast copy, navigation, or a page +fallback. + +A boundary may catch when it can: + +- recover or select a fallback; +- retry with an explicit policy; +- translate a known domain outcome into another established contract; +- attach context required by an incident reporter; +- present feedback owned by that interaction; +- perform cleanup in `finally`; +- terminate propagation intentionally. + +If none applies, let the failure propagate. + +## Avoid Catch, Log, And Rethrow + +Do not catch only to log and rethrow: + +```ts +// Avoid: the caller, query cache, or global boundary may report it again. +try { + return await loadWorkspace(workspaceId) +} catch (error) { + log.error("Workspace load failed", error, { workspaceId }) + throw error +} +``` + +Prefer direct propagation: + +```ts +return loadWorkspace(workspaceId) +``` + +Catch and rethrow only when adding information that cannot be supplied at a +higher boundary. Prefer `cause` or the repository's established typed contract; +do not flatten the original value into a generic message. + +Never swallow silently. An intentionally ignored failure must be demonstrably +expected. Add a short comment or a `warn` with safe context when the reason is +not obvious. + +## Preserve The Failure Origin + +A `try`/`catch` does not erase an existing error stack by itself. The origin is +usually lost when code replaces the value with a new error, converts it to a +string, mutates its stack, reports only the catch location, or crosses an +async/process boundary without carrying diagnostic origin separately. + +- Rethrow the same value when no new contract is required. +- If a new error genuinely adds boundary meaning, preserve the received value + as `cause`; do not pretend the wrapper stack is the original failure stack. +- Never append consumer or call-site text to `error.stack`. Keep + `errorStack`, `cause`, and an optional `originStack` as separate facts. +- Capture an origin before starting work whose later stack cannot lead back + across a timer, event, Query execution, worker, native module, or RPC call. +- Report native or host failures in the runtime where the original error still + exists. Correlate runtimes with safe request or operation identifiers instead + of reconstructing an error in the receiving runtime. +- Treat source maps and symbolication as part of the production error path, not + optional build decoration. + +Read `references/trace-preservation.md` whenever changing catches, wrappers, +async scheduling, global reporting, native boundaries, or stack diagnostics. + +## Preserve Errors As Received + +Keep transport and domain error identity, status, code, details, and cause +available to the owner that understands them. + +Do not introduce: + +- a universal application error class; +- recursive error normalization or serialization; +- registries that must change for every new error type; +- message-string matching when a typed field exists; +- catch-all conversion merely to satisfy a local helper. + +Create an `Error` fallback only at a boundary that requires an actual `Error` +instance, such as an exception-reporting SDK or render boundary, and retain the +original value as `cause` when useful. + +An established privacy or redaction adapter may need a safe representation for +one specific sink. Keep it at that sink. If legacy code must produce a redacted +replacement `Error`, verify that it does not replace the only surviving failure +stack: retain the original locally when safe and carry its unchanged standard +stack explicitly in the sink-local error or record. Do not promote that +exception into a generic error conversion pipeline. + +## Make Incident Reporting Explicit + +Reporting means sending a failure to an incident or diagnostic backend. It is +not synonymous with logging or showing feedback. + +- Report once, at the boundary with the best safe diagnostic context. +- Keep provider SDKs behind the repository's reporting facade or + provider-owned logger transport. +- Do not turn every `logger.error()` into an incident unless the repository + explicitly defines the error level that way. When it does, reporting emits + one logger record and must not call the provider separately. +- Do not make `showError()` secretly report through a boolean option. +- Avoid reporting expected cancellation, validation failures, authorization + outcomes, or other explicitly handled states unless project policy requires + it. +- Keep identifiers and operational metadata; exclude secrets, credentials, + raw payloads, and unnecessary personal data. + +When both local logging and incident capture are needed, ensure they represent +one intentional flow rather than two independent observers reporting the same +failure. + +## Use Global Boundaries As Backstops + +Initialize global rejection and uncaught-error listeners once at the runtime +composition root. They catch failures that escaped normal ownership; they are +not a replacement for local recovery. + +Treat these listeners as terminal observers. Pass the supplied error or +rejection reason into the configured logger with automatic origin capture +disabled. When an incident provider is a logger transport, this one record must +fan out to both persistence and incident capture; do not call the provider +again from the listener. + +Use React, route, or application error boundaries to: + +- report otherwise-unhandled render failures; +- replace a broken subtree with stable fallback UI; +- offer an appropriate reset, retry, reload, or navigation action; +- isolate the smallest useful region when recovery can remain local. + +Do not use an error boundary for event-handler or awaited action failures that +the interaction boundary can handle directly. + +## Treat Retry As Product Behavior + +Retry only when the operation is safe to repeat and the policy is explicit. +Consider idempotency, attempt limits, delay, cancellation, offline behavior, +and whether the user should remain in control. + +Do not add retries merely to hide an unknown failure. Do not combine automatic +retry at several layers. One owner must decide when attempts stop and what +recovery becomes visible. + +## Coordinate TanStack Query Deliberately + +TanStack Query can observe a failure at the query or mutation function, cache, +hook, call site, and error boundary. Choose one technical reporting path and one +presentation owner. + +Read `references/tanstack-query.md` whenever work touches Query or Mutation +failure behavior, `mutateAsync`, cache callbacks, background errors, +`throwOnError`, retry, or duplicate reporting. + +## Keep User Presentation Separate + +After handling policy is decided, delegate presentation to **user-feedback**: + +- field or form errors; +- inline region errors; +- toasts, banners, dialogs, and alerts; +- success, warning, and informational outcomes; +- retry or recovery controls visible to the user. + +The same catch boundary may call the reporter and the feedback layer when it +truly owns both decisions, but the helpers themselves remain independent. + +## Verify The Result + +Before finishing: + +- trace one failure from origin to its terminal owner; +- confirm it is not logged or reported twice; +- confirm expected cancellation and domain outcomes are not incidents; +- confirm low-level code does not own product copy or UI; +- confirm the original typed error remains available where needed; +- confirm no catch, wrapper, reporter, or transport overwrites the original + error stack; +- confirm any async or cross-runtime origin was captured before the boundary and + kept separately; +- confirm production stack artifacts match the exact release or update; +- confirm retry has one owner and a stop condition; +- confirm global listeners and boundaries initialize once; +- confirm incident context contains no secrets; +- confirm the user receives one appropriate presentation, if any. + +## Related Skills + +- External transports, generated error contracts, and query or mutation + ownership → **api-integration**. +- Diagnostic records, child context, persistence, and telemetry transports → + **logging**. +- Toasts, inline messages, dialogs, fallbacks, and recovery copy → + **user-feedback**. +- Form validation and server field errors → **forms**. +- Component and async-region rendering → **components**. +- Native exception boundaries and privileged provider wiring → + **native-integration**. +- Placement of reporting modules and boundaries → **file-structure**. diff --git a/.agents/skills/error-handling/agents/openai.yaml b/.agents/skills/error-handling/agents/openai.yaml new file mode 100644 index 0000000..6ab0673 --- /dev/null +++ b/.agents/skills/error-handling/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Error Handling" + short_description: "Error ownership, reporting, and recovery" + default_prompt: "Use $error-handling to design or review error propagation, catch boundaries, reporting, and recovery behavior." diff --git a/.agents/skills/error-handling/references/tanstack-query.md b/.agents/skills/error-handling/references/tanstack-query.md new file mode 100644 index 0000000..278ee2c --- /dev/null +++ b/.agents/skills/error-handling/references/tanstack-query.md @@ -0,0 +1,195 @@ +# TanStack Query Failure Flow + +Use this reference when changing query or mutation error propagation, +reporting, retry, feedback, or Error Boundary behavior. + +## Keep The Query Function Honest + +Let a query or mutation function reject with the original transport or domain +error. Do not resolve a failure as ordinary data and do not convert every error +into a generic application type. + +The API module owns transport behavior and canonical cache effects. It does not +own page-specific feedback. + +## Choose One Reporting Observer + +`QueryCache` and `MutationCache` callbacks can provide one application-wide +diagnostic observer: + +```ts +const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError(error, query) { + if (isExpectedCancellation(error)) return + + reportError(error, { + captureOrigin: false, + module: "query", + context: { + queryFamily: String(query.queryKey[0] ?? "unknown"), + }, + }) + }, + }), +}) +``` + +This is a policy option, not a mandatory pattern. Reporting every cache error +without classification creates noise from cancellation, background refetch, +offline behavior, and expected authorization or domain outcomes. + +`captureOrigin: false` is deliberate in a declarative Query observer: the +query's rejection stack is primary, while a newly captured cache-callback stack +only identifies observation. A Mutation observer may instead receive an +earlier `originStack` captured per execution as shown below. + +Do not serialize an entire query key, mutation variables, or transport payload +into reporting context. Select only the safe identifiers needed for diagnosis. + +When cache-level reporting exists, do not report the same failure again from a +component merely because it also presents feedback. + +### Do Not Forge A Combined Stack + +A Query or Mutation error stack usually identifies the transport or domain +failure but may not identify the component or interaction that started the +operation. Keep those facts separate. + +Do not mutate the shared error: + +```ts +// Avoid: retries, observers, and reporters now see a modified error object. +error.stack += `\n--- used in ---\n${callSite}` +``` + +If a consumer origin matters, capture it before the asynchronous execution and +pass it as diagnostic metadata: + +```ts +const origin = captureTraceOrigin() + +try { + await updateProject.mutateAsync(input) +} catch (error) { + reportError(error, { + originStack: origin.stack, + context: { projectId }, + }) +} +``` + +This call-site owner replaces cache-level reporting for that same failure; the +cache may still own retry and canonical mutation lifecycle. Do not create two +incident reports. + +`meta` is suitable for stable mutation ownership or a registration stack +captured when options or a hook are created. That identifies the consumer +definition, not necessarily the later `mutate()` or `mutateAsync()` invocation. +Name it honestly, for example `registrationStack`, and do not present it as the +exact invocation stack. + +If an application requires one global MutationCache reporter with the actual +`mutate()` or `mutateAsync()` invocation chain, capture synchronously in the +global `onMutate` callback and associate the origin with that Mutation instance: + +```ts +const mutationOrigins = new WeakMap<object, TraceOrigin>() + +const mutationCache = new MutationCache({ + onMutate(_variables, mutation) { + mutationOrigins.set(mutation, captureTraceOrigin()) + }, + onError(error, _variables, _onMutateResult, mutation) { + reportError(error, { + originStack: mutationOrigins.get(mutation)?.stack, + context: { + mutationFamily: String( + mutation.options.mutationKey?.[0] ?? "unknown", + ), + }, + }) + }, + onSettled( + _data, + _error, + _variables, + _onMutateResult, + mutation, + ) { + mutationOrigins.delete(mutation) + }, +}) +``` + +Capture at the start of `onMutate`, before returning or awaiting anything. In +current TanStack Query execution, that callback is entered from the imperative +mutation call before mutation work crosses its asynchronous boundary, so its +raw stack still includes the consumer chain. The WeakMap keeps concurrent +Mutation instances isolated and releases settled entries. + +Verify this behavior against the installed TanStack Query version because +callback signatures and execution details are library contracts that may +change. A restored or resumed persisted mutation has no live caller origin; do +not invent one. + +Do not put the origin in mutation variables, use one mutable "latest origin" +slot, mutate the error, or parse a fixed number of stack lines. + +## Separate Background Failure From Empty Failure + +A background refetch may fail while usable cached data remains visible. Do not +replace that data with a full error screen or emit a global toast automatically. +Choose a subtle stale or retry indication when the product needs one. + +An initial query with no usable data may render an inline error region with a +retry action. Follow the async rendering waterfall from **components** and the +presentation rules from **user-feedback**. + +## Understand Mutation Control Flow + +`mutateAsync()` returns a promise and rejects when `mutationFn` rejects. An +ordinary `try`/`catch` can therefore classify a submit or action failure: + +```ts +try { + await updateProject.mutateAsync({ projectId, name }) + showSuccess("Project updated") +} catch (error) { + showError(error, { + fallbackMessage: "Unable to update the project", + }) +} +``` + +`throwOnError` controls whether a stored mutation error is propagated during +render to an Error Boundary. It is not required for `mutateAsync()` to reject. + +The callback-based `mutate()` does not return an awaitable failure. Use its +callbacks when that style already owns the interaction; do not wrap it in +`try`/`catch` and expect the asynchronous error there. + +## Keep Lifecycle Ownership Stable + +- Keep canonical invalidation and cache updates in reusable mutation options + owned by **api-integration**. +- Keep interaction-specific success and failure feedback at the call site. +- Do not spread mutation options and silently replace their lifecycle + callbacks. +- Do not duplicate retry between TanStack Query, a transport interceptor, and + the component. +- Use Error Boundary propagation only for failures the local interaction + cannot or should not recover from. + +## Test The Policy + +Cover at least: + +- initial failure without data; +- background failure with cached data; +- expected cancellation; +- mutation rejection handled at the call site; +- repeated or concurrent mutations without origin cross-contamination; +- retry without repeated mutation of the same error stack; +- one technical report for one failure; +- retry exhaustion and recovery. diff --git a/.agents/skills/error-handling/references/trace-preservation.md b/.agents/skills/error-handling/references/trace-preservation.md new file mode 100644 index 0000000..a975c17 --- /dev/null +++ b/.agents/skills/error-handling/references/trace-preservation.md @@ -0,0 +1,180 @@ +# Error Trace Preservation + +Use this reference when a catch, wrapper, async boundary, reporter, or native +call makes a failure point to infrastructure instead of its real origin. + +## Contents + +- [Catch Is Not The Loss](#catch-is-not-the-loss) +- [Propagation And Wrapping](#propagation-and-wrapping) +- [Async Origins](#async-origins) +- [Process And Native Boundaries](#process-and-native-boundaries) +- [Reporting](#reporting) +- [Framework Boundaries](#framework-boundaries) +- [Verification](#verification) + +## Catch Is Not The Loss + +An existing `Error` normally keeps the stack captured when it was created. +Merely entering a `catch` does not replace it: + +```ts +try { + return await loadProject() +} catch (error) { + throw error +} +``` + +This catch is still unnecessary when it makes no decision, but it does not by +itself reset an `Error` stack. + +The destructive versions are: + +```ts +catch (error) { + throw new Error(String(error)) +} + +catch (error) { + reportError(new Error("Project failed")) +} + +catch (error) { + error.stack += `\n${new Error().stack}` +} +``` + +They replace identity, discard typed fields or cause, or corrupt the original +stack. + +## Propagation And Wrapping + +Propagate the received value unchanged unless a boundary owns a real contract +translation. + +When a wrapper adds essential meaning, retain the original: + +```ts +try { + await storage.open(projectId) +} catch (error) { + throw new Error("Opening project storage failed", { + cause: error, + }) +} +``` + +The wrapper stack and cause stack describe different layers. Do not concatenate +them into one string. Let the established incident provider represent the cause +chain when it supports it; otherwise retain the original cause locally and add +small safe context explicitly. + +Do not create a wrapper only to change wording for a log or user message. Logger +messages and user feedback can add context without replacing the failure. + +## Async Origins + +An error stack describes where the failure occurred. It may not show who +scheduled the work after a timer, event, queue, Query execution, worker, or RPC +boundary. + +Capture a supplemental origin synchronously before crossing that boundary: + +```ts +const origin = captureTraceOrigin() + +runInBackground() + .catch(error => { + reportError(error, { + originStack: origin.stack, + context: { operationId }, + }) + }) +``` + +Keep the origin outside the error object. Capture one origin per execution so +parallel work cannot overwrite another operation's diagnostic. + +Do not capture inside the later `catch` and label it as the operation origin. It +only identifies the observer. + +## Process And Native Boundaries + +Do not expect an `Error` instance to preserve class identity, non-enumerable +fields, cause, and stack through browser workers, Electron IPC, native modules, +or another RPC implementation. + +Choose ownership before transport: + +- report a host/native failure in the host/native runtime while the original + error exists there; +- report a renderer/JavaScript failure in that runtime before sending a + persistent log record; +- send minimal standard diagnostic strings only when another runtime must + persist or display them; +- use a request or operation identifier to correlate the two runtime records. + +Never recreate a received string as `new Error(receivedMessage)` and present its +receiver-side stack as the remote failure origin. + +## Reporting + +Pass the original error to the incident reporter whenever it accepts the value. +Attach safe module, operation, request, and supplemental origin information +through the provider's scope or context API. + +If a reporter requires an `Error` but the thrown value is not one, create a +fallback only at that reporter boundary: + +```ts +const reportable = + error instanceof Error + ? error + : new Error(message, { cause: error }) +``` + +That fallback stack identifies the conversion boundary. Do not describe it as +the original failure location. + +Avoid global `Error.prepareStackTrace` overrides and stack-string rewriting. +They are engine-specific and can interfere with source-map and incident +provider processing. + +An established redaction adapter is another replacement boundary. If it creates +a safe `Error` instance or record for one sink, preserve the received error in +the local flow when safe and copy its unchanged standard stack explicitly into +that sink-local diagnostic shape. A newly constructed redacted error otherwise +points to the sanitizer, which makes the privacy layer look like the failure +origin. + +## Framework Boundaries + +- TanStack Query cache callbacks are observers. Preserve the rejection and use + explicit ownership metadata; read `tanstack-query.md` for exact invocation + origins. +- Disable automatic logger-origin capture in observers that have no earlier + operation origin. Their stack is still available when intentionally needed, + but it must not be mislabeled as the caller. +- React error boundaries receive an error stack and a component stack. Preserve + both as separate diagnostic fields. +- Global `error` and `unhandledrejection` listeners are terminal backstops. Use + the supplied `Error` or rejection reason; do not replace it merely to add the + words "Unhandled error". +- A retry is another execution, not permission to append another section to the + same error stack. + +## Verification + +For each changed flow, record: + +1. where the original error is created; +2. which layer first catches it; +3. which layer owns recovery or reporting; +4. which discontinuity requires a supplemental origin; +5. which runtime retains the original error; +6. how production frames map to the exact shipped artifact. + +Force the same failure through retries and concurrent executions. Confirm that +the original stack remains byte-for-byte unchanged and each execution retains +its own origin. diff --git a/.agents/skills/file-structure/SKILL.md b/.agents/skills/file-structure/SKILL.md new file mode 100644 index 0000000..d4dd483 --- /dev/null +++ b/.agents/skills/file-structure/SKILL.md @@ -0,0 +1,246 @@ +--- +name: file-structure +description: Use whenever work creates, moves, renames, deletes, splits, merges, or relocates files or directories; introduces a module or folder; adds or expands barrel files; changes which files are public or private through entrypoints or package exports; or requires deciding where code should live. Do not use for content-only edits that preserve existing file placement. +--- + +# File Structure + +Keep the source tree aligned with real ownership. Apply this skill before making +structural changes, not after files have already been placed. + +## Inspect Before Placing + +Read the affected tree before deciding where anything belongs: + +1. Find the nearest existing files with the same responsibility. +2. Inspect local naming, co-location, entrypoint, test, and export conventions. +3. Trace current consumers and package exports before moving or hiding a file. +4. Prefer the repository's coherent existing convention. Do not introduce a + parallel structure merely because another project uses it. +5. If nearby conventions conflict, follow the boundary that best matches actual + ownership and keep the change scoped. Do not reorganize unrelated code. + +## Apply Recursive Encapsulation + +Treat every directory that represents a module as the same recursive structure: + +- its entrypoint is the public boundary seen by consumers outside that directory; +- files owned only by that module live inside the directory, behind the boundary; +- an owned child may become a module with its own entrypoint and private + implementation; +- repeat the same public-outside, private-inside rule at every depth. + +This is about visibility and ownership, not file kind. Apply it equally to +components, hooks, schemas, services, adapters, formatters, fixtures, tests, +state, and any other implementation. + +In this model, consumers sit outside or above the boundary they consume, while +owned implementation goes inward or below it. "Above" and "below" describe the +ownership tree; they do not require every consumer to be located in a literal +parent directory. + +Place every file at the narrowest boundary that contains all current consumers: + +- one file or module only: keep it inside that owner's boundary; +- several files in one feature: keep it at that feature boundary; +- sibling features or pages: place it at their nearest shared owner; +- unrelated features or packages: promote it only when multiple real consumers + require the same contract or behavior. + +Do not promote code for hypothetical reuse. The second real use is evidence to +reconsider placement, not an automatic reason to create a global abstraction. +When consumers change, move the file to their new narrowest common owner instead +of exporting a private path merely to avoid relocation. + +Keep supporting files near the module they verify or support unless the +repository has a stronger established convention. + +## Name Child Folders from Their Context + +Name an owning module by its capability, domain, or responsibility. Inside that +owner, use the conventional role folder that best explains the children: +`components/`, `hooks/`, `schemas/`, `services/`, `adapters/`, `formatters/`, +`fixtures/`, `tests/`, or the repository's established equivalent. + +These names are contextual, not global categories. `Feature/components/` means +components private to Feature; `Feature/hooks/` means hooks private to Feature. +Do not lift them into a broad root-level dump merely because several files share +the same technical kind. + +Avoid ambiguous catch-all locations such as `misc/`, overly broad `helpers/`, +overly broad `utils/`, `common/`, `shared/`, or a global `types.ts` when their +contents do not form one coherent boundary. Prefer a precise contextual role, +direct co-location, or the actual capability name. + +Do not create a directory for a single leaf file unless the directory establishes +a real module boundary, owns private children, or follows a required repository +convention. Promote a growing leaf to a folder without changing its public import +surface when practical. + +## Separate Public Surface from Implementation + +Structure packages and substantial modules by visibility: + +- expose only intentional, stable entrypoints at the public boundary; +- use an `internal/` directory only when it represents a real package- or + application-level non-public boundary and the repository uses that model; +- organize `internal/` by capability rather than turning it into another dump; +- use each internal module's entrypoint as its local boundary; +- do not expose internal implementation again through package subpath exports; +- keep platform-specific entrypoints separate when consumers genuinely need + different runtime surfaces. + +Do not create `internal/` inside every owner. A file is already private when it +lives inside its owner's directory and is absent from that owner's public +entrypoint. Use the contextual role folders above, or keep a private leaf beside +the entrypoint. + +## Minimize Barrels + +Prefer direct imports. Do not add an `index.ts` to every directory merely because +the directory exists. + +Distinguish three different uses: + +- an implementation entrypoint such as `Component/index.tsx`, where the file + defines or composes the public component, is not a barrel; +- a small curated public surface at a real package or feature boundary may + re-export a few intentional capabilities; +- a convenience file that mechanically re-exports directory contents is a + barrel and should normally not exist. + +Allow a barrel only when the boundary is real, the exported set is small, +cohesive, stable, and immediately obvious, and consumers should not know the +internal paths. There is no useful numeric limit: aim for one curated surface per +real public module, not one barrel per folder. + +Inside private implementation, import the owning file directly. Never: + +- create chains where one barrel imports from or re-exports another barrel; +- use `export *` to sweep a directory into an API; +- re-export private files merely to shorten an import path; +- combine unrelated capabilities behind one convenience entrypoint; +- keep expanding a barrel after the origin and ownership of its exports stop + being obvious. + +When barrels begin multiplying, replace convenience re-exports with direct +imports before the dependency graph becomes opaque or cyclic. + +## Recursive Structure Examples + +Choose the shape from the current owner and its children. These examples express +the same rule in different contexts; they are not mandatory folder names. + +### Component ownership + +```text +Workspace/ +├── index.tsx +├── hooks/ +│ └── use-workspace-state.ts +└── components/ + ├── Canvas.tsx + └── Toolbar/ + ├── index.tsx + ├── hooks/ + │ └── use-toolbar-shortcuts.ts + └── components/ + └── ToolbarAction.tsx +``` + +`use-workspace-state.ts` is shared by Workspace children. The shortcut hook and +action component are private to Toolbar and therefore live inside Toolbar. + +### Composed hook + +```text +hooks/ +└── use-selection/ + ├── index.ts + └── hooks/ + ├── use-keyboard-selection.ts + └── use-pointer-selection.ts +``` + +The surrounding feature imports `use-selection/index.ts`. Its composing hooks +stay behind that hook module's boundary. + +### Recursive feature ownership + +```text +FileUpload/ +├── index.tsx +├── components/ +│ ├── Dropzone/ +│ │ ├── index.tsx +│ │ └── hooks/ +│ │ └── use-drag-state.ts +│ └── UploadProgress.tsx +├── hooks/ +│ ├── use-upload-progress.ts +│ └── use-upload-queue.ts +└── validators/ + ├── file-size.ts + └── file-type.ts +``` + +The root `hooks/` and `validators/` contain behavior shared inside FileUpload. +`use-drag-state.ts` belongs only to Dropzone, so it moves into that component's +own boundary. The same rule therefore repeats inside the feature without an +undifferentiated `internal/`. + +### Single private leaf + +```text +Summary/ +├── index.tsx +└── format-total.ts +``` + +Do not create `internal/`, `helpers/`, or `formatters/` for one private leaf +unless local convention requires it. Direct co-location already communicates +ownership. + +### Real package-level internal boundary + +```text +package/src/ +├── index.ts +├── native.ts +└── internal/ + ├── protocol/ + │ └── index.ts + └── runtime/ + └── index.ts +``` + +Here `internal/` is meaningful: package consumers may import only the declared +public entrypoints, while several non-public capabilities remain package-owned. + +Outsiders import the public module, not its private children. Do not expose an +internal file merely because another internal file needs it; place both under +their narrowest common owner. + +When any leaf gains private children, promote it to a folder with an entrypoint +and continue the same structure recursively. + +## Defer Route And Page Trees To Routing + +When a structural change creates, moves, or reorganizes route branches, page +boundaries, route declarations, or route-tree assembly, apply **routing** as the +authoritative structure. Use this skill recursively for the non-routing +implementation inside the page or layout boundary selected by **routing**. + +## Make Structural Changes Complete + +When moving, renaming, splitting, merging, or deleting files: + +1. Update all imports, aliases, entrypoints, package exports, tests, fixtures, + tooling configuration, and generated registries that reference the old path. +2. Move the implementation; do not leave duplicate old and new copies unless a + deliberate compatibility layer is required. +3. Preserve public import paths when the public contract is not meant to change. +4. Remove obsolete empty structure only when it is fully owned by the change. +5. Search for the old path and old exported names after the move. +6. Run the repository's focused structural verification, then its required + typecheck, lint, tests, and build as appropriate. diff --git a/.agents/skills/file-structure/agents/openai.yaml b/.agents/skills/file-structure/agents/openai.yaml new file mode 100644 index 0000000..d1349ef --- /dev/null +++ b/.agents/skills/file-structure/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "File Structure" + short_description: "Organize files by ownership and visibility" + default_prompt: "Use $file-structure to place files behind the narrowest correct module boundary." diff --git a/.agents/skills/forms/SKILL.md b/.agents/skills/forms/SKILL.md new file mode 100644 index 0000000..f3761de --- /dev/null +++ b/.agents/skills/forms/SKILL.md @@ -0,0 +1,249 @@ +--- +name: forms +description: Use whenever work creates, changes, reviews, or debugs a form, form field, input flow, validation schema, submit flow, create or edit form, server field error, react-hook-form register or Controller integration, or Zod form schema. Trigger even when the request only describes a product form such as sign-in, settings, search, filters, a dialog form, or an editable resource without naming the form libraries. +--- + +# Forms + +Build forms with `react-hook-form` + Zod and treat each form as a thin, +declarative boundary around rendered fields and submission. + +A field control may be: + +- an existing `Ui*` design primitive; +- a composition of several primitives; +- a product component with its own meaningful controlled or uncontrolled API. + +Inspect the repository's existing UI and product components before creating a +new control. Follow **components** for primitive reuse, product-component +ownership, and controlled/uncontrolled component contracts. + +## Schema and Types + +- Define the Zod schema close to the form unless the same form contract is + genuinely shared. Colocation beats premature extraction. +- The form schema mirrors the fields represented by the rendered form. It is + the readable inventory, validation contract, and value contract for that UI. +- Type form values with `z.infer<typeof formSchema>`. Never hand-maintain a + parallel `FormValues` interface. +- Wire validation through `zodResolver(formSchema)`. +- A form schema is not an API DTO and must not be shaped around one. Do not + create API types, proxy types, or DTO schemas inside the form. +- Give every stable field an explicit initial value through `defaultValues`. + Never use `undefined` as the value of a controlled field or `Controller`. +- Treat browser-managed file inputs separately. For conditionally mounted or + dynamically registered fields, deliberately choose registration, + unregistration, and default-value behavior instead of relying on omission. + +## Create And Edit Forms + +One form component may support both creation and editing by accepting an +optional existing entity: + +- entity present → edit mode; +- entity absent → create mode. + +Absence must mean create mode only. The owner must resolve loading, error, and +not-found states before mounting an edit form; do not temporarily render create +mode while an entity is still loading. + +Populate `defaultValues` explicitly from the entity: + +```ts +const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: entity?.name ?? "", + description: entity?.description ?? "", + }, +}); +``` + +Select only fields represented by the form. Do not spread the entire entity +into `defaultValues`. + +Treat these defaults as the initial snapshot for that form instance. When the +owner switches to a different entity, remount the form by identity: + +```tsx +<EntityForm key={entity?.id ?? "create"} entity={entity} /> +``` + +Key by stable identity, not by the entity object or all of its values. A +background query update for the same entity must not silently overwrite dirty +user input. + +Create both mutation hooks unconditionally, as required by React's hook rules, +and choose the operation inside `handleSubmit`: + +```ts +const onSubmit = handleSubmit(async (values) => { + if (entity) { + await updateMutation.mutateAsync({ + id: entity.id, + name: values.name, + description: values.description, + }); + return; + } + + await createMutation.mutateAsync({ + name: values.name, + description: values.description, + }); +}); +``` + +Do not use `useEffect` to copy entity data into form state. After a successful +update, call `reset()` with the committed form values only when the form remains +open and should establish a new pristine baseline. A form that closes or +navigates away does not need that reset. + +## Numeric and Coerced Inputs + +- HTML inputs usually produce strings. Decide explicitly what an empty value + means before converting it. +- Use `z.coerce.number()` only when its empty-string and coercion behavior is + correct for that field. Remember that `Number("")` is `0`. +- Prefer a deliberate `z.preprocess`, `register(..., { valueAsNumber: true })`, + or `setValueAs` when empty, optional, and invalid values need different + semantics. +- Apply the same discipline to dates, booleans, selects, and any other control + whose rendered value differs from its form value. + +## Validation + +- Per-field rules live on the field schema with a user-facing message + (`z.string().trim().min(1, "...")`). +- Cross-field rules use `.refine()` / `.superRefine()` and **must set `path`** so the + error attaches to the right field (e.g. confirm-password mismatch -> `path: ["confirmPassword"]`). + +## Submit and Pending State + +- Prefer one `handleSubmit(async (values) => { ... })` as the complete + form-submission flow. Keep request preparation, `mutateAsync`, field-error + handling, and success UX readable in that handler. +- Call the typed mutation with an explicit object built directly from form + values: + +```ts +const onSubmit = handleSubmit(async (values) => { + await mutation.mutateAsync({ + email: values.email, + password: values.password, + }); +}); +``` + +The mutation's typed parameter is the API contract and verifies the object. +Do not create form-to-DTO mappers, conversion helpers, proxy types, or local API +schemas. Form-only fields simply do not appear in the mutation call. + +- Use `mutateAsync` so the submit handler can await the operation and express + success and failure in normal control flow. +- `mutateAsync` rejects when the mutation function rejects, so an ordinary + `try`/`catch` handles submit failures without `throwOnError`. That option + controls render-phase propagation to an Error Boundary; do not enable it just + to make form error handling work. If the mutation function resolves an error + as a successful value or otherwise swallows it, correct or follow the + established contract in **api-integration** rather than compensating inside + the form. +- Compute one pending flag: + `isPending = formState.isSubmitting || mutation.isPending`. +- Drive duplicate-submission prevention and the submit control's pending + presentation from that flag. +- Keep canonical invalidation and API-wide mutation behavior inside the API + module's `mutationOptions`. See **api-integration**. +- Prefer local success UX directly after the awaited mutation: show feedback, + `reset()`, navigate, or close the form. +- Do not spread canonical mutation options and then overwrite their + `onSuccess` or `onError` callbacks in the form. + +## register vs Controller + +- **`register()` by default.** Native-input `Ui*` wrappers (`UiInput`, `UiTextarea`) + expose a native `value`/`onChange`/`ref` contract and bind directly: `{...register("email")}`. +- **`Controller` only when a component lacks a compatible native input + contract.** Custom primitives such as selects, switches, checkboxes, radio + groups, and segmented controls commonly need it. +- Product components may also act as fields. When their state must be owned by + the form, compose `Controller` with the component's controlled API + (`value`/`onValueChange` or its semantic equivalent). Do not move RHF into the + product component merely to make it usable by a form. +- Compose react-hook-form's `<Controller>` directly with the existing primitive + or product component at the field call site. +- Never create or reuse `ControlledUi{X}` components, `controlled.tsx` modules, or + reusable wrappers around `useController`. Keep RHF ownership visible in the form and + keep the `Ui*` layer independent of the form library. + +## Field Composition + +Prefer the established shadcn-style field composition when the project provides +it: a field container groups its label, control, optional description, and +validation error. The container owns field-level layout and invalid state; the +control keeps its own visual and interaction contract. + +This is a recommendation, not a mandatory component tree. Inspect existing +forms and the repository's UI primitives before choosing exact components, +names, props, or ordering. + +An adapted shadcn `Field` composition may look like: + +```tsx +<UiField data-invalid={!!errors.email}> + <UiFieldLabel htmlFor="email">Email</UiFieldLabel> + <UiInput + {...register("email")} + id="email" + type="email" + autoComplete="email" + aria-invalid={!!errors.email} + /> + {errors.email?.message && ( + <UiFieldError errors={[errors.email]} /> + )} +</UiField> +``` + +The names are illustrative. Use the project's equivalent rather than creating +these wrappers solely to match the example. Never restyle a base control to make +it fit a form; compose around it. + +## Server Field Errors + +- Catch submit failures in the `handleSubmit` callback when the form needs to + classify them. +- When the API returns a field-specific failure, attach it with + `setError(field, { message })`, then return. +- Detect the specific failure from the typed transport error (status/code/detail), not by + string-matching a generic message. Transport errors are thrown as received and preserve + their status/message/detail (owned by **api-integration**) — exploit that here instead of + flattening. +- General submit failures surface through the project's feedback flow, not + `setError`. +- Use one presentation path for each failure. Do not show a general mutation + error and then show the same failure again as a field error. + +## Cross-References + +- `Ui*` primitives, product fields, field-error presentation, and + controlled/uncontrolled component APIs → **components**. RHF `Controller` + composition remains owned here. +- `mutationOptions`, query/mutation key factories, and cache invalidation placement + -> **api-integration** (separate skill from state management). +- Submit success and failure feedback (`showError` / `showSuccess` or the + repository's equivalent) -> **user-feedback**. +- Submit failure propagation, reporting, retry, and Error Boundary policy -> + **error-handling**. +- Page/feature placement of the form module and where its files live -> **file-structure**. + +## Anti-Patterns + +- No `useEffect` to sync derived form state — derive in render or via watched values. +- No restyling base UI components to make a field fit; wrap them. +- No ternary expressions for conditional JSX. Use a simple logical condition + for one optional element and an ordered IIFE for multiple render branches. +- No form-to-DTO mapper layer, local API types, or duplicate API schemas. + +See `references/patterns.md` for terse, stack-specific patterns that should be +adapted to the repository's existing components and contracts. diff --git a/.agents/skills/forms/references/patterns.md b/.agents/skills/forms/references/patterns.md new file mode 100644 index 0000000..cb39511 --- /dev/null +++ b/.agents/skills/forms/references/patterns.md @@ -0,0 +1,172 @@ +# Form Patterns + +Stack-specific, adaptable skeletons for react-hook-form, Zod, TanStack Query, +and a shadcn-style component system. Replace component and feedback names with +the repository's established equivalents. + +## (a) Basic Form + Mutation Submit with isPending + +```tsx +const formSchema = z.object({ + title: z.string().trim().min(1, "Title is required"), + quantity: z.number().positive("Must be greater than 0"), +}); +type FormValues = z.infer<typeof formSchema>; + +function ResourceForm() { + const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = + useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { title: "", quantity: 1 }, + }); + + const mutation = useMutation(createResourceMutationOptions()); + const isPending = isSubmitting || mutation.isPending; + + const onSubmit = handleSubmit(async (values) => { + // mutateAsync rejects when mutationFn rejects. throwOnError is only for + // render-phase Error Boundary propagation and is not required here. + try { + await mutation.mutateAsync({ + title: values.title, + quantity: values.quantity, + }); + + showSuccess("Saved"); + reset(); + } catch (error) { + showError(error, { fallbackMessage: "Unable to save" }); + } + }); + + const submitLabel = (() => { + if (isPending) return "Saving..."; + return "Save"; + })(); + + return ( + <form onSubmit={(e) => void onSubmit(e)}> + <UiInput + {...register("quantity", { valueAsNumber: true })} + type="number" + aria-invalid={!!errors.quantity} + /> + <UiButton type="submit" disabled={isPending}> + {submitLabel} + </UiButton> + </form> + ); +} +``` + +## (b) Cross-Field Refine (set path) + +```ts +const schema = z + .object({ + password: z.string().min(8), + confirmPassword: z.string().min(1, "Confirm your password"), + }) + .refine((v) => v.password === v.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], // attach error to the right field + }); +``` + +## (c) Build The Typed Mutation Parameter In handleSubmit + +```ts +const onSubmit = handleSubmit(async (values) => { + await signUpMutation.mutateAsync({ + email: values.email, + password: values.password, + }); +}); +``` + +`confirmPassword` remains part of `formSchema` because it is rendered in the +form, but it is simply not included in the typed mutation argument. + +## (d) Server Field Error via setError + +```ts +const onSubmit = handleSubmit(async (values) => { + try { + await mutation.mutateAsync({ + username: values.username, + password: values.password, + }); + } catch (error) { + if (isFieldTakenError(error)) { // detect from typed transport error + setError("username", { message: "Username is already taken" }); + return; // handled — do not rethrow + } + showError(error, { fallbackMessage: "Unable to save" }); + } +}); +``` + +## (e) Controller Fallback (no native input contract) + +```tsx +// Use for primitives or product fields that expose a controlled value contract. +<Controller + control={control} + name="enabled" + render={({ field }) => ( + <UiSwitch checked={field.value} onCheckedChange={field.onChange} /> + )} +/> +``` + +## (f) Shared Create / Edit Form + +Mount edit mode only after its entity is available. Remount when its identity +changes: + +```tsx +<EntityForm key={entity.id} entity={entity} /> +``` + +Use a distinct create-mode instance: + +```tsx +<EntityForm key="create" /> +``` + +Inside the shared form: + +```tsx +function EntityForm({ entity }: { entity?: Entity }) { + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: entity?.name ?? "", + description: entity?.description ?? "", + }, + }); + + const createMutation = useCreateEntityMutation(); + const updateMutation = useUpdateEntityMutation(); + + const onSubmit = form.handleSubmit(async (values) => { + if (entity) { + await updateMutation.mutateAsync({ + id: entity.id, + name: values.name, + description: values.description, + }); + return; + } + + await createMutation.mutateAsync({ + name: values.name, + description: values.description, + }); + }); + + // Render the same form fields for both modes. +} +``` + +Do not synchronize later entity updates into the form with `useEffect`. diff --git a/.agents/skills/localization/SKILL.md b/.agents/skills/localization/SKILL.md new file mode 100644 index 0000000..1ed1b71 --- /dev/null +++ b/.agents/skills/localization/SKILL.md @@ -0,0 +1,500 @@ +--- +name: localization +description: Use whenever work adds, changes, reviews, or debugs user-facing copy in an application that uses localization, including visible text, labels, placeholders, accessibility text, validation messages, notifications, translation calls, source-text or semantic keys, locale resources, interpolation, plurals, context variants, typed translation APIs, enum labels, locale switching, or translation linting. Trigger even when the request mentions only changing UI wording without explicitly naming i18n. +--- + +# Localization + +When an application uses localization, every user-facing message goes through +its translation layer: + +- visible text and actions; +- labels, descriptions, placeholders, and hints; +- empty, loading, success, and error messages; +- validation feedback and notifications; +- `aria-label`, image `alt`, and other accessibility text. + +An application without localization should not receive a speculative partial +i18n layer. Adopt this skill when localization already exists, is being +introduced, or a translatable-string audit is explicitly requested. + +Inspect the installed localization library, initialization, source locale, +resource files, translator APIs, plural/context conventions, and validation +commands before changing copy. + +## Use Source Text As The Default Key + +For ordinary UI copy, use the canonical source-language text itself as the +translation key: + +```ts +translate("Cancel"); +translate("Create a new project"); +translate("Changes are saved automatically"); +``` + +The source catalog repeats the source text as its value: + +```json +{ + "Cancel": "Cancel", + "Changes are saved automatically": "Changes are saved automatically", + "Create a new project": "Create a new project" +} +``` + +This makes the application searchable from its interface: copying visible text +and searching the repository should lead directly to the resource and ordinary +call sites. + +Do not replace ordinary copy with invented page-tree, component, kebab-case, or +UI-role identifiers such as: + +```text +projects.details.header.delete-project-btn +settings.notifications.description-body +``` + +File location and presentation role are not stable message identities. Moving a +component or changing a label into a button must not rename its translation. + +Use the exact source message, including meaningful capitalization and +punctuation. A wording change creates a new source key and requires existing +translations to be reviewed. Remove the obsolete key after migrating every call +site. + +## Reuse Meaning, Disambiguate Context + +Repeated source text is not inherently a collision. + +- Same text and same meaning → reuse one key. +- Same text but different meaning or required translation → disambiguate it. + +Prefer the localization engine's context feature: + +```ts +translate("Open", { context: "action" }); +translate("Open", { context: "state" }); +``` + +Store the base message and the engine's contextual variants in every locale. +For example, i18next uses its configured `contextSeparator` (`_` by default): + +```jsonc +// source locale +{ + "Open": "Open", + "Open_action": "Open", + "Open_state": "Open" +} +``` + +```jsonc +// another locale +{ + "Open": "Відкрити", + "Open_action": "Відкрити", + "Open_state": "Відкрито" +} +``` + +The call site passes the unsuffixed source key and semantic context. The +localization engine resolves the appropriate contextual resource key: + +```text +translate("Open", { context: "action" }) -> Open_action +translate("Open", { context: "state" }) -> Open_state +``` + +Do not append the context suffix manually at call sites. Follow the installed +engine's exact separator, fallback, typing, and plural/context composition +rules. Keep a base entry when the engine uses it as the non-contextual fallback. + +Context describes linguistic meaning, not file placement. Add a translator +comment when the library or catalog supports one. + +If the established runtime has no context mechanism, use the project's explicit +disambiguation convention while keeping the source text searchable, for +example: + +```ts +translate("Open|action"); +translate("Open|state"); +``` + +```jsonc +// source locale +{ + "Open|action": "Open", + "Open|state": "Open" +} +``` + +```jsonc +// another locale +{ + "Open|action": "Відкрити", + "Open|state": "Відкрито" +} +``` + +Do not invent a delimiter or alternate context system when the project already +has one. + +Short words such as `"Save"`, `"Cancel"`, `"Continue"`, and `"Name"` follow the +same rule. Use the source text directly when its meaning is shared; add context +only for a real ambiguity. + +## Keep Whole Messages Together + +Message length alone is not a reason to invent a semantic key. A sentence or +paragraph that forms one translatable unit may remain its own source-text key: + +```ts +translate( + "Deleting this project will permanently remove its settings and associated data.", +); +``` + +Never split natural language into separately translated fragments merely to +shorten a key. Translators must be able to reorder the whole message. + +Use a stable semantic id with an explicit source/default value only when the +content is genuinely managed as structured content rather than ordinary UI +copy, for example: + +- multi-paragraph help or onboarding content; +- localized Markdown or rich text; +- legal documents; +- large independently maintained content blocks. + +```ts +translate("project-deletion-explanation", { + defaultValue: projectDeletionExplanation, +}); +``` + +## Interpolation, Plurals, And Formatting + +Keep placeholders inside the complete source message and pass their values +through the translator: + +```ts +translate("Delete {{name}}?", { name }); +translate("Created by {{author}}", { author }); +``` + +Do not concatenate or template together fragments of natural language. A +rendered message containing a dynamic value may not exactly match its source +key, but its static wording remains searchable. + +### Plurals + +Use the localization engine's plural/select support for counts and grammatical +variants. Do not choose English singular/plural forms with component logic. +Follow the installed engine's resource format because plural categories differ +between locales. + +For example, i18next JSON v4 resolves plural variants from a base key and the +required `count` option: + +```ts +translate("{{count}} project", { count }); +``` + +```jsonc +// source locale +{ + "{{count}} project": "{{count}} projects", + "{{count}} project_zero": "No projects", + "{{count}} project_one": "{{count}} project", + "{{count}} project_other": "{{count}} projects" +} +``` + +```jsonc +// a locale with additional plural categories +{ + "{{count}} project": "{{count}} проєктів", + "{{count}} project_zero": "Немає проєктів", + "{{count}} project_one": "{{count}} проєкт", + "{{count}} project_few": "{{count}} проєкти", + "{{count}} project_many": "{{count}} проєктів", + "{{count}} project_other": "{{count}} проєкту" +} +``` + +The base entry keeps the ordinary typed lookup and non-contextual fallback +explicit. The engine selects `_zero`, `_one`, `_few`, `_many`, or `_other` +according to the active locale. A locale only defines the categories required +by the installed engine and its plural rules; do not copy English categories +blindly. + +For i18next, the option must be named `count`. Other engines may use another +resource shape or ICU message syntax. Reuse the project's installed plural +mechanism and verify its current official documentation. + +Context and plural variants may be combined. Pass both `count` and `context`; +the engine composes their resource suffixes. Never construct `_one`, `_other`, +or combined suffixes in application code. + +### Locale-Aware Formatting + +Format dates, times, numbers, percentages, units, and currencies with the +project's locale-aware formatter. Do not interpolate locale-insensitive +`toString()` output into a translated sentence. + +When the localization engine supports `Intl`-backed formatting, keep the +formatter inside the complete message. For i18next versions that support its +built-in formatters: + +```jsonc +{ + "Total: {{amount, currency(USD)}}": "Total: {{amount, currency(USD)}}", + "Updated on {{date, datetime}}": "Updated on {{date, datetime}}", + "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete": "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete" +} +``` + +```ts +translate("Total: {{amount, currency(USD)}}", { + amount: 1250, +}); + +translate("Updated on {{date, datetime}}", { + date: updatedAt, + formatParams: { + date: { + dateStyle: "medium", + timeStyle: "short", + }, + }, +}); + +translate( + "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete", + { + progress: 0.725, + }, +); +``` + +The active locale controls separators, currency presentation, ordering, and +date/time wording. Other locales keep the same placeholders but may move them +within the message. + +If the localization engine does not own formatting, format through the +project's locale-aware formatter and interpolate the result: + +```ts +translate("Total: {{amount}}", { + amount: formatCurrency(amount, { currency: "USD", locale: activeLocale }), +}); +``` + +## Use Stable Domain Keys For Enums And Machine Values + +Enums and other closed machine-defined sets already have stable identities. +They do not need source text as their lookup key. + +When a value is presented in multiple ownership areas, keep one exhaustive, +typed translator: + +```ts +const STATUS_KEYS = { + [Status.Active]: "enums.status.active", + [Status.Archived]: "enums.status.archived", +} satisfies Record<Status, TranslationKey>; + +export const translateStatus = (status: Status) => + translate(STATUS_KEYS[status]); +``` + +The resource values remain searchable: + +```json +{ + "enums.status.active": "Active", + "enums.status.archived": "Archived" +} +``` + +Apply this to stable statuses, roles, modes, categories, and similar constants. +The key must be mechanically derived from the domain value, not creatively +named after one component. + +Use a local source-text key when a one-off label only happens to resemble an +enum value. Do not route unrelated copy through a shared enum translator. + +## Derive Key Types From The Source Catalog + +The canonical source locale is the key authority. Derive key and language types +from real resources instead of maintaining manual unions: + +```ts +import source from "./locales/en.json"; + +export const resources = { + en: { translation: source }, + uk: { translation: uk }, +} as const; + +export type Language = keyof typeof resources; +export type TranslationKey = keyof typeof source; +``` + +For a flat source-text catalog, prefer the simple `keyof` type. Do not build a +recursive path utility that: + +- permits intermediate objects as translation results; +- generates both dot and bracket forms; +- duplicates the localization library's own key inference; +- slows TypeScript as the catalog grows. + +When the library supports resource-based type augmentation, connect it directly +to the source catalog. For i18next: + +```ts +declare module "i18next" { + interface CustomTypeOptions { + defaultNS: "translation"; + keySeparator: false; + nsSeparator: false; + returnObjects: false; + resources: { + translation: typeof source; + }; + } +} +``` + +Keep runtime configuration and type augmentation aligned. A flat natural-key +i18next catalog normally requires: + +```ts +i18n.init({ + resources, + keySeparator: false, + nsSeparator: false, +}); +``` + +Otherwise periods or colons inside sentences may be interpreted as key or +namespace separators. Follow the exact installed library version and official +documentation when configuring this foundation. + +If the project uses multiple catalogs or namespaces, split them for real +ownership, loading, or deployment reasons. Do not recreate page-tree +namespacing merely to organize keys visually. + +JSON resources provide useful key inference but may not preserve enough literal +information for fully typed interpolation variables. If stronger typing is +needed, prefer source resources defined with `as const` in TypeScript or +generated declarations. Never hand-maintain a second resource interface. + +## Keep Translator APIs Honest + +Prefer the localization library's typed translator directly. A project wrapper +may adapt ergonomics, but it must preserve: + +- the inferred key type; +- option and interpolation types; +- context and plural overloads; +- the real return type. + +Do not weaken the boundary with `any`, `as unknown as string`, or a general +`string` key. + +Do not custom-memoize translated results by only `key + options`. The active +locale, loaded resources, and runtime language changes also affect the result. +Use the localization engine's own resource behavior unless the project has a +proven locale-aware caching abstraction. + +Inside reactive UI, use the project's translation hook so the component +responds to locale changes. Outside React, use the established direct +translator. + +Do not eagerly translate module-level constants when the application can change +locale without reloading. Store keys and translate at the consumption boundary, +or construct locale-sensitive schemas/options through the project's established +flow. Module-level translation is acceptable only when the application +deliberately reloads on locale change or otherwise guarantees reevaluation. + +Do not pass a translator through component props when each component can obtain +the project translator from its normal context. + +## Validate Every Locale + +Typing call sites from the source locale proves that a used source key exists. +It does not automatically prove that every other locale has the same keys. + +Treat localization validation as layered. The foundation must detect missing, +extra, and orphaned keys; incompatible placeholders; incomplete plural/context +variants; invalid resource shapes; and stale keys after copy changes. + +No single successful command proves all of these properties. Combine the +repository's resource validator, exact parity or policy checks, source usage +analysis when it understands the project's translator API, and type-checking. + +When using or considering `@lingual/i18n-check`, read +[references/i18n-check.md](references/i18n-check.md) before trusting it. Its +resource checks are useful, but source parsing and i18next plurals have limits. + +Prefer automatic correction for deterministic ordering, but do not silently +fabricate translations. A source-language value copied into another locale must +remain visibly untranslated according to the project's workflow. + +Keep resource keys deterministically sorted when the project stores catalogs in +version control. + +## What Not To Translate + +- User-generated or backend-provided content. +- User names, record ids, filenames, and machine-readable codes. +- Proper-noun brands that intentionally remain identical across locales. +- Raw dynamic values that should be formatted rather than translated. + +Translate the surrounding static message as one unit. Do not assume +backend-provided labels are localized unless the API contract guarantees it. + +## Workflow + +1. Inspect the localization runtime, source locale, resources, typed translator, + context/plural conventions, and validation commands. +2. Find every affected user-facing string, including accessibility, + notification, and validation copy. +3. Search the exact source text before adding it. +4. Reuse an existing key when both source text and meaning match. +5. Add linguistic context when identical source text needs a different + translation. +6. Use a typed domain key only for an enum/machine value or a justified + structured-content exception. +7. Add the source entry and update other locales through the project's + translation workflow. +8. Replace the call site with the typed translator and keep interpolation or + plural logic inside the message. +9. Remove replaced or stale keys. +10. Run locale sorting/parity validation, formatting, linting, and type-checking. + +## Avoid + +- invented page/component/element keys for ordinary copy; +- namespaces derived from file-system position; +- duplicating identical messages per call site; +- sharing identical source text that needs different linguistic context; +- semantic ids for ordinary sentences merely because they are long; +- sentence fragments and translated-string concatenation; +- runtime-generated or untyped translation keys; +- manual key unions or duplicate resource interfaces; +- recursive path types for a flat source-text catalog; +- casts that hide object or missing-key results; +- custom translation memoization that ignores locale; +- assuming source-locale typing validates every locale. + +## Related Skills + +- Localized validation messages and form lifecycle → **forms**. +- User-facing component copy and locale-reactive rendering → **components**. +- Localized success, error, warning, notification, and recovery feedback → + **user-feedback**. +- Placement of localization modules and resource files → **file-structure**. diff --git a/.agents/skills/localization/references/i18n-check.md b/.agents/skills/localization/references/i18n-check.md new file mode 100644 index 0000000..8a2e157 --- /dev/null +++ b/.agents/skills/localization/references/i18n-check.md @@ -0,0 +1,569 @@ +# i18n-check Integration Reference + +Use this reference when a project already uses `@lingual/i18n-check`, when +introducing it, or when deciding whether its output is sufficient for a +localization change. + +## Contents + +- [Role And Boundaries](#role-and-boundaries) +- [Supported Checks](#supported-checks) +- [Recommended Commands](#recommended-commands) +- [Required Validation Layers](#required-validation-layers) +- [Day-To-Day Workflows](#day-to-day-workflows) +- [Source Usage Compatibility](#source-usage-compatibility) +- [Source-Text Key Edge Cases](#source-text-key-edge-cases) +- [Interpolation And Rich Messages](#interpolation-and-rich-messages) +- [Plurals And Context](#plurals-and-context) +- [Catalog And File Edge Cases](#catalog-and-file-edge-cases) +- [Ignore, Exclude, And Reporting](#ignore-exclude-and-reporting) +- [Version-Specific Caveats](#version-specific-caveats) +- [Integration Checklist](#integration-checklist) + +## Role And Boundaries + +Treat `i18n-check` as a resource compatibility checker, not as the complete +localization authority. + +It is effective at comparing a canonical source locale with target locale files +and returning a non-zero exit code for detected problems. This makes it useful +for local validation, pre-commit hooks, and CI. + +It does not translate copy, migrate renamed keys, judge translation quality, or +prove that every runtime localization path works. A successful run only proves +the checks that were actually enabled and supported by the installed version. + +Inspect all of the following before configuring it: + +1. The installed `@lingual/i18n-check` version. +2. The localization engine and message format. +3. The source locale and resource layout. +4. The project's translator functions, hooks, and rich-text components. +5. The runtime key, namespace, plural, and context separators. +6. Existing type-check, lint, extraction, and CI commands. + +Consult the installed package and current official documentation instead of +assuming that behavior described for another version still applies: + +- <https://lingual.dev/i18n-check/> +- <https://github.com/lingualdev/i18n-check> + +## Supported Checks + +The CLI exposes four checks: + +| Check | Intended guarantee | +| --- | --- | +| `missingKeys` | A source key exists in each matched target resource. | +| `invalidKeys` | Source and target messages preserve compatible message elements. | +| `unused` | A source-locale key was not found in parsed application source. | +| `undefined` | A parsed application key does not exist in the source locale. | + +By default, use `missingKeys` and `invalidKeys` as the dependable resource +validation layer. `unused` and `undefined` require `--unused`/`-u` and are only +reliable when the parser recognizes the project's actual call-site syntax. + +For i18next messages, `invalidKeys` can detect common structural changes such +as: + +- a missing, added, or renamed interpolation variable; +- escaped versus unescaped interpolation; +- changed i18next nesting expressions; +- missing or changed rich-text tags; +- changed interval-plural expressions supported by the parser. + +It deliberately ignores ordinary source and target text differences. It cannot +determine whether a translation is accurate, grammatical, current, or +appropriate for its context. + +The CLI can load one or multiple locale folders and supports common layouts +such as one file per locale, one folder per locale, and matching multiple files +inside each locale folder. Discovery and matching do not prove that an expected +locale or file exists; validate that separately. + +Format behavior is version-specific. Inspect support for ICU, i18next, +react-intl, and next-intl in the installed package instead of assuming that +every check works equally for every format. + +## Recommended Commands + +Make the resource contract explicit: + +```json +{ + "scripts": { + "validate:i18n:resources": "i18n-check -l src/localization/locales -s en -f i18next -o missingKeys invalidKeys" + } +} +``` + +Adjust paths, source locale, and format to the inspected project. Supported +resource inputs are JSON and YAML. + +Run the resource check: + +- after adding, removing, or changing localized copy; +- in the normal local validation workflow; +- in a pre-commit hook when it remains fast; +- in CI as the authoritative blocking gate. + +Pre-commit alone is insufficient because it can be bypassed. + +Only add source usage validation after a compatibility test: + +```json +{ + "scripts": { + "validate:i18n:usage": "i18n-check -l src/localization/locales -s en -f i18next -u src -o unused undefined" + } +} +``` + +Do not merge this command into a blocking workflow merely because it runs. First +confirm that it finds representative calls through every project translator, +hook, component wrapper, namespace, context, and dynamic-key registry. + +Prefer the standard reporter for actionable local and CI output. Use the +summary reporter only when exact affected keys are available through another +artifact. + +When the CLI cannot represent the project's resource ownership, use the +package's documented public check functions to compose project tooling. Verify +the installed exports and keep project-specific policy checks outside the +package. Do not depend on private `dist/` modules. + +## Required Validation Layers + +Use `i18n-check` as one part of this stack: + +| Layer | Responsibility | +| --- | --- | +| Resource validation | Missing source keys and structurally incompatible messages. | +| Project policy/parity validation | Expected locales/files, reverse base-key parity, plural/context completeness, and source-text policy. | +| Type-checking | Valid call-site keys, options, interpolation values, context, and count types. | +| Source usage analysis | Undefined and orphaned keys when the parser understands the project API. | +| Runtime or integration tests | Locale switching, fallback behavior, lazy resources, rich messages, and formatting. | +| Human review | Meaning, grammar, tone, cultural fit, and product terminology. | + +The project-specific policy validator should cover gaps that `i18n-check` does +not reliably cover: + +- an explicit manifest of supported locales and required resource files; +- bidirectional parity for ordinary keys and context identities after grouping + locale-specific plural variants; +- target-only and orphaned keys; +- duplicate resource keys; +- source-text key conventions and justified semantic-key exceptions; +- required plural categories for each locale; +- combined context and plural variants; +- placeholder compatibility in target-only plural categories; +- deterministic resource ordering when required. + +Do not weaken these guarantees to fit one tool. Extend the validation stack. + +## Day-To-Day Workflows + +### Add Copy + +1. Search for an existing source message with the same meaning. +2. Add the source entry. +3. Add or explicitly queue every target translation through the established + workflow. +4. Update the typed call site. +5. Run resource validation, project parity validation, and type-checking. + +`missingKeys` should identify a target locale that did not receive the new +source key. It cannot create the translation. + +### Change Source-Text Copy + +A wording change is a key migration: + +```ts +translate("Delete project?"); +translate("Delete this project?"); +``` + +Perform it atomically: + +1. Find every call site of the old exact message. +2. Add the new source key. +3. Carry each existing translation to the new key and review it against the new + meaning. +4. Update all call sites. +5. Remove the obsolete key from every locale. +6. Run resource validation, reverse parity, source usage validation when + compatible, and type-checking. + +`missingKeys` will report the new key when targets still contain only the old +key. It will not identify the change as a rename, migrate translations, or +report the old target-only key. + +Capitalization, punctuation, and meaningful whitespace are part of a +source-text key. Even a small wording edit creates a distinct key. + +### Change Interpolation + +When changing: + +```text +Hello {{name}} +Hello {{user}} +``` + +update the source message, every target message, the call-site option, and its +type. `invalidKeys` can catch a target that retained `{{name}}`; it does not +prove that the call site supplies `user`. + +### Remove A Feature + +Remove its call sites and owned source keys, then remove the same keys from +every target locale. + +The basic resource check will not report target-only leftovers. Use reverse +parity and, when compatible, `unused`. + +### Add A Locale Or Resource File + +Register the locale in the project's explicit locale manifest, create every +required resource file, then run validation. + +Do not depend on directory discovery to prove completeness. A checker cannot +report a locale or file that it was never told must exist. + +## Source Usage Compatibility + +The i18next source parser commonly recognizes direct forms such as: + +```ts +t("Save"); +i18n.t("Save"); +const { t } = useTranslation(); +t("Save"); +``` + +It also recognizes `Trans` and can be given additional component wrapper names +through `--parser-component-functions`. + +Do not assume that it recognizes project wrappers: + +```ts +translate("Save"); + +const tx = useTranslate(); +tx("Save"); +``` + +The CLI option for additional component functions extends `Trans` component +recognition; it does not necessarily configure arbitrary translation function +or hook names. Verify the installed version before relying on wrapper support. + +Do not rename a clear project API merely to satisfy a static parser. Prefer a +thin project-aware source checker or contribute configurable function support +upstream. + +Avoid importing private `dist/` parser modules into permanent project tooling. +Internal package paths and options are not a stable public contract. + +### Dynamic Keys + +Static analysis cannot generally resolve: + +```ts +t(`status.${status}`); +t(prefix + id); +translate(KEY_FROM_RUNTIME_DATA); +``` + +Prefer explicit typed registries for closed machine-defined values: + +```ts +const STATUS_KEYS = { + active: "enums.status.active", + archived: "enums.status.archived", +} as const; +``` + +Validate the registry exhaustively with TypeScript and include it in the +project's localization policy check. Do not scatter ignores for dynamic keys. + +### False Usage + +Inspect whether the selected source paths include: + +- tests and fixtures; +- stories and examples; +- generated code; +- comments containing `t(...)`; +- dead or unreachable modules. + +These can keep a production key classified as used. Some parser versions scan +translation-looking calls in comments. + +An unrelated application function named `t` can create the opposite problem: +the parser may treat its string argument as a translation key. + +Object-returning calls may also cause a whole subtree to be skipped: + +```ts +t("countries", { returnObjects: true }); +``` + +Treat an unused report as static evidence that still requires ownership-aware +review. + +### Namespaces + +Test multiple namespaces explicitly. Some versions treat a key as used without +fully proving that it was used through the correct namespace. + +Source-text keys containing `:` need particular care. A source parser may split: + +```ts +t("Error: invalid value"); +``` + +as namespace `Error` plus key ` invalid value`, even when runtime i18next uses +`nsSeparator: false` or natural-key detection. + +Resource-only checks remain useful, but `unused` and `undefined` are unreliable +until this case passes a project smoke test. + +## Source-Text Key Edge Cases + +`i18n-check` compares resource keys; it does not enforce the project's key +policy. + +Add project validation for these invariants: + +- ordinary source-text keys follow the canonical source wording; +- the canonical source value has not silently drifted from its key; +- context variants intentionally differ from their base key; +- semantic keys are limited to enums, machine values, or documented structured + content; +- a flat literal key cannot collide with an equivalent nested resource path. + +Do not apply a universal `key === value` rule blindly. Context variants and +justified semantic keys legitimately differ: + +```json +{ + "Open": "Open", + "Open_action": "Open", + "enums.status.active": "Active" +} +``` + +Literal periods and colons must agree with runtime `keySeparator` and +`nsSeparator` behavior. The resource checker does not prove runtime lookup. + +Treat plural and context suffixes as reserved according to the installed +localization engine. A normal semantic key that accidentally ends in `_one` or +`_other` may be normalized as a plural form by tooling. + +## Interpolation And Rich Messages + +Use `invalidKeys` to protect the structural contract between source and target +messages, then keep call-site correctness in the type system. + +Validate representative cases: + +- reordered placeholders remain valid; +- renamed or missing placeholders fail; +- escaped and unescaped interpolation cannot be mixed accidentally; +- formatting directives preserve their arguments; +- nested translation expressions reference real keys; +- rich-text tags preserve the elements required by the renderer. + +The checker may compare a set of message elements without proving their exact +semantic nesting, rendering behavior, or component binding. + +It also does not validate: + +- that a custom formatter is registered; +- that date, time, number, currency, or unit inputs have the right runtime type; +- that the active locale reaches the formatter; +- that a nested `$t(...)` reference resolves; +- that translated prose retained the intended meaning. + +Test those boundaries through types and focused runtime tests. + +## Plurals And Context + +i18next plural categories vary by locale and are based on `Intl.PluralRules`. +The `count` option is required for plural selection. Context and plural suffixes +can be combined. + +Consult the installed engine's current documentation: + +- <https://www.i18next.com/translation-function/plurals> +- <https://www.i18next.com/translation-function/context> + +Do not treat a successful i18next `missingKeys` check as proof of plural +completeness. Some `i18n-check` versions normalize plural suffixes to a base key. +Consequently, one existing target variant may satisfy the check even when other +required categories are absent. + +For example, this target may pass base-key presence despite being incomplete: + +```json +{ + "project_one": "{{count}} project" +} +``` + +Locale-specific categories can also escape structural comparison when the +source locale does not define the same category. A broken target `_few` or +`_many` message may therefore remain undetected. + +The project policy validator must: + +1. Determine cardinal categories for each supported locale. +2. Validate ordinal categories separately when used. +3. Treat `_zero` as an explicit optional override unless product requirements + make it mandatory. +4. Require the correct variants for every pluralized base key. +5. Repeat the check for every context variant. +6. Validate interpolation elements across all target-only categories. +7. Respect the installed engine's configured plural and context separators. + +Do not copy English plural categories into every locale merely to satisfy a +checker. + +## Catalog And File Edge Cases + +### Reverse Parity + +The normal comparison direction is source to target. A target-only key can pass +`missingKeys` and `invalidKeys`. + +Run a locale-aware reverse comparison or bidirectional parity check to find: + +- old target keys left after a source-text rename; +- keys removed from the source but not from targets; +- accidental target-only additions. + +Normalize recognized plural-category suffixes before deciding that a target key +is extra. Preserve context identity: a target-only context remains suspicious, +while a target locale may legitimately require `_few` or `_many` when the +source locale does not. + +### Missing Files And Locales + +Directory discovery only compares files it finds and can match. It does not +prove that a deleted locale or resource file was expected. + +Maintain an explicit locale/resource manifest and verify it before content +comparison. + +### Empty And Null Values + +Verify installed-version behavior for empty strings, `null`, `false`, and +numeric values. Some implementations use truthiness for missing checks, making +an intentionally empty string appear missing. + +Prefer string-valued leaf messages unless the established engine explicitly +supports another resource shape. + +### Duplicate Keys + +Standard `JSON.parse` accepts duplicate object keys and silently keeps the last +value. A checker that reads resources through `JSON.parse` cannot report the +overwritten definition. + +Use a duplicate-aware JSON parser, formatter, or lint rule before running +resource comparison. YAML parser behavior must also be verified rather than +assumed. + +### Nested And Flat Resources + +Many checkers flatten nested objects into dotted paths. Avoid mixing: + +```json +{ + "a.b": "flat", + "a": { + "b": "nested" + } +} +``` + +This can produce a collision after flattening even when the raw JSON keys are +different. + +### Unsupported Sources + +The CLI resource loader supports JSON and YAML. TypeScript resource objects, +remote catalogs, generated bundles, or custom formats require a generation +step or public API integration. + +Source parsing commonly covers `js`, `jsx`, `ts`, and `tsx`. Verify other +extensions such as `mjs`, `mts`, MDX, Vue, or Svelte before enabling blocking +usage checks. + +## Ignore, Exclude, And Reporting + +Use `--ignore` and `--exclude` only for an explicit, reviewed exception. + +Each exception must identify: + +- the exact key, locale, file, or ownership boundary; +- why the normal invariant does not apply; +- whether the exception is temporary; +- how removal will be detected. + +Avoid broad wildcard ignores. Verify their matching semantics in the installed +version; some versions use substring matching for wildcard prefixes and can +silence unrelated keys. + +Do not exclude an entire locale merely because it is incomplete unless product +policy explicitly allows that locale to ship incomplete. + +Use the standard reporter when developers must fix individual keys. A summary +is useful for metrics, but counts alone are insufficient remediation output. + +## Version-Specific Caveats + +The following behaviors were observed in `@lingual/i18n-check@0.9.5`. Re-test +them when the installed version differs: + +- the i18next source parser defaults to `t`, `useTranslation`, + `withTranslation`, and `Trans`; +- custom `translate` and custom translation hooks are not configurable through + the documented CLI; +- parsed keys containing `:` are split as namespace-prefixed keys; +- context usage normalization assumes `_`; +- plural suffixes are collapsed during missing and usage checks; +- namespace-aware usage matching is incomplete; +- `returnObjects: true` creates skippable key subtrees; +- source-file `--exclude` behavior does not necessarily match locale-file + exclusion behavior; +- wildcard ignores use broad substring matching; +- missing checks treat falsy target values as absent; +- interpolation prefix/suffix comparison contains an implementation defect. + +Do not preserve these limitations as timeless rules. They describe why the +installed tool must be tested against the real project instead of trusted by +name. + +## Integration Checklist + +Before declaring localization validation complete: + +- [ ] Identify the canonical source locale. +- [ ] Verify every expected locale and resource file through a manifest. +- [ ] Run `missingKeys` and `invalidKeys`. +- [ ] Run locale-aware reverse or bidirectional base-key parity. +- [ ] Detect duplicate resource keys before parsing destroys that evidence. +- [ ] Validate source-text, context, and semantic-key conventions. +- [ ] Validate cardinal and ordinal plural categories per locale. +- [ ] Validate combined context/plural variants. +- [ ] Confirm placeholder compatibility in every locale-specific variant. +- [ ] Type-check keys, translator options, interpolation values, and `count`. +- [ ] Prove the source parser recognizes every project translator API before + enabling `unused` or `undefined`. +- [ ] Test natural keys containing punctuation, especially `:` and `.`. +- [ ] Review every ignore or exclude exception. +- [ ] Run focused runtime tests for locale switching, fallback, lazy resources, + formatting, and rich messages. +- [ ] Run the blocking validation in CI. diff --git a/.agents/skills/logging/SKILL.md b/.agents/skills/logging/SKILL.md new file mode 100644 index 0000000..eabedde --- /dev/null +++ b/.agents/skills/logging/SKILL.md @@ -0,0 +1,327 @@ +--- +name: logging +description: Use whenever work adds, changes, reviews, debugs, or consumes application logging in web, React Native, Electron, Electrobun, or another client runtime. Trigger for logger calls, structured log or event records, child loggers, scopes and context, log levels, console usage, transports, browser or native persistence, Electron renderer-to-main logging, logging RPC, batching, buffering, flushing, file logs, logger call sites, origin stacks, source maps, symbolication, Sentry or OpenReplay integration, telemetry breadcrumbs, tracked application events, or deciding where logging infrastructure belongs, even if the request only mentions diagnostics, traces, persisted logs, analytics events, or replacing console calls. +--- + +# Logging + +Use one structured logging contract and facade across client runtimes. Keep +platform delivery behind transports so application code does not know whether a +record goes to a console, browser storage, a native file, or another process. + +Logging records diagnostic facts. It does not decide how an error is handled, +whether a user sees feedback, whether an operation retries, or whether an +exception becomes a tracked incident. + +Keep the failure stack and the logging origin distinct. An error stack answers +where the failure was created or thrown. An origin stack answers where a log or +asynchronous operation was initiated. Preserve both without rewriting either. + +Explicit application events may use the same facade and transport pipeline when +the record keeps its event identity. Do not infer analytics events by parsing +ordinary human-readable log messages. + +## Inspect Before Extending + +Before changing logging: + +1. Find the public logger facade and its factory. +2. Find where the root logger is configured for each runtime. +3. Find registered transports and their lifecycle. +4. For desktop applications, trace renderer-to-host delivery and identify which + runtime owns file access. +5. Check whether the repository already has log persistence, retention, + telemetry breadcrumbs, or an established privacy policy. +6. Check how development and production stacks are source-mapped or + symbolicated for every runtime and release channel. + +Extend the existing foundation when it preserves the boundaries below. Do not +create a second application logger for one feature or runtime. + +## Keep One Contract, Not One Runtime Instance + +Main, renderer, preload, workers, browser pages, and React Native JavaScript run +in separate environments. They cannot share one physical singleton. + +Provide: + +- one platform-neutral logger contract; +- one logger factory and facade behavior; +- one process-local root logger per runtime; +- platform transports selected at the runtime's composition root. + +Place that contract at the narrowest real common owner. A monorepo with several +runtimes may justify a package; a single React Native application may only need +`core/logging`. Do not manufacture a package boundary that has no consumers. + +Feature code imports only its configured logger facade. It must not import file +writers, RPC clients, telemetry SDKs, or storage adapters merely to emit a log. + +## Use A Direct Record Flow + +Keep the core pipeline small: + +```text +logger method + -> merge child scope + -> create one structured log or event record + -> fan out to configured transports +``` + +Do not insert generic normalization, conversion, sanitization, enrichment, or +middleware chains. A transport may perform the smallest representation change +required by its own boundary. Keep that change inside the transport. + +Read `references/facade-and-records.md` when defining or changing the logger +contract, record shape, child behavior, messages, levels, or error argument. + +## Separate Stable Scope From Event Context + +Use `child(...)` for values repeated by several records: + +```ts +const log = logger.child({ + module: "authentication", +}) + +const refreshLog = log.child({ + operation: "refresh-session", + requestId, +}) +``` + +Pass values belonging to one event to the log method: + +```ts +refreshLog.info("Session refreshed", { + userId, + expiresAt, +}) +``` + +When the facade supports explicit tracked events, use the dedicated method: + +```ts +const mtlsLog = logger.child({ module: "mtls" }) + +mtlsLog.event("mtls_provision", { + method: "jwt", +}) +``` + +The event name is a stable machine contract. It is not an ordinary `info` +message reinterpreted later by a telemetry transport. + +Keep scopes small and mostly flat. Prefer identifiers and operational metadata +over entire entities or live runtime objects. + +Snapshot the scope and event context shallowly when creating a logger or record +so later caller mutation cannot change a queued record. Do not deep-clone or +walk values; callers should provide small, boundary-compatible fields. + +Use stable human-readable messages and put variable values in context: + +```ts +// Avoid +log.info(`Project ${projectId} opened`) + +// Prefer +log.info("Project opened", { projectId }) +``` + +## Choose Levels By Meaning + +- `debug`: detailed diagnosis that may be disabled or dropped. +- `info`: a meaningful lifecycle event, state transition, or completed action. +- `warn`: an unexpected but recoverable state or an applied fallback. +- `error`: a failed operation or diagnostic error event. + +Do not log every function call or every successful request. Logging volume must +remain useful enough to inspect. + +Let each transport choose its own level threshold. Feature code must not know +whether a particular environment persists `debug`, `info`, or only higher +levels. + +An explicit application event is a separate record kind, even when its local +console or file representation uses the `info` level. + +## Preserve Diagnostic Origins + +Do not mistake the logger implementation frame for the real call site. +Capturing a stack inside a transport is too late: it points to the transport, +queue flush, RPC handler, or file writer. + +- Keep an original `Error` value unchanged in the local record. +- Capture an optional origin stack synchronously at the public logger call or + before scheduling work that will finish across an async boundary. +- Perform automatic capture in the public method itself. Capturing in a shared + private emitter or origin resolver leaves that helper as the leading frame. +- Store the origin separately, for example as `originStack`; never append it to + `error.stack`. +- Let infrastructure observers explicitly suppress automatic capture when they + have no earlier origin. A Query cache callback, global error listener, RPC + handler, and file writer must not label their observation stack as the + operation origin. +- Preserve a received renderer or worker origin when a host persists the + record. Do not replace it with the host ingestion stack. +- Do not remove frames by a fixed `split(...).slice(n)` rule. Stack formats and + wrapper depth differ between V8, JavaScriptCore, and Hermes. +- Do not capture a stack for every production `debug` or `info` record without + measuring the cost. Configure a deliberate policy, commonly all enabled + levels in development and `warn`/`error` or explicitly traced operations in + production. + +Read `references/trace-origins.md` whenever work touches stack traces, logger +call sites, async origins, source maps, symbolication, Query or Mutation +diagnostics, or cross-runtime log delivery. + +## Compose Platform Transports At Bootstrap + +The logger facade should exist before optional platform services initialize. +Keep a console transport available as the early and emergency fallback, then +register persistence, RPC, or telemetry transports at bootstrap. + +- Web may use console, browser persistence, and remote transports. +- React Native may use console, native persistence adapters, and telemetry. +- Electron or Electrobun main may use console and native file transports. +- Electron or Electrobun renderer may use console and a buffered RPC transport + that delivers records to the host-owned file transport. +- Tests may use an in-memory or no-op transport. + +Provider-specific telemetry transports belong to the provider integration +module. For example, an OpenReplay renderer package may expose a transport that +maps explicit event records to `trackEvent`, while application features remain +unaware of OpenReplay. If the repository deliberately defines every `error` +record as incident-worthy, the provider transport may also map those records to +`captureException`. That choice makes `logger.error` part of the incident +contract: do not keep a second reporting facade that captures the same failure. +If diagnostic errors and incidents differ, represent that distinction +explicitly instead of guessing from a message. + +Read `references/platform-transports.md` when adding transports, file +persistence, Electron renderer-to-main delivery, batching, flushing, or +transport lifecycle. + +Read `references/proven-platform-patterns.md` for compact web, React Native, +Electron, and Electrobun implementation shapes and the edge cases they expose. + +## Keep Transports Isolated + +A transport must never break application behavior or prevent another transport +from receiving a record. + +- Keep logger methods synchronous from the caller's perspective. +- Put queues and batching inside asynchronous transports, not the core logger. +- Bound every queue. +- Make registration idempotent and removable for tests, HMR, and teardown. +- Support best-effort `flush()` and `dispose()` where a transport needs them. +- Report a broken transport directly through a guarded console fallback, not + through the same logger. + +Do not hide an unbounded pre-initialization queue in the logger. Early records +may go only to the console until optional transports are ready. + +## Keep Platform Boundaries Honest + +In a desktop renderer, file access belongs to the privileged host. Deliver +structured records through the existing native-RPC foundation instead of +granting renderer code filesystem access. + +Application code still calls the common facade. The RPC client is an +implementation detail of the renderer transport, not the public logger used by +features. + +Prefer one batch ingestion method over duplicating `debug`, `info`, `warn`, and +`error` as RPC methods. The host must persist received renderer records without +re-logging them through its own root logger; re-logging changes source metadata, +timestamps, and can create loops or duplicates. + +Register logging RPC in the native handler registry, but do not expose it as an +agent capability merely because the repository uses the same registry for both. +Operational transport methods and user- or agent-invokable capabilities have +different semantics. + +## Do Not Build An Error Conversion System + +The logger may accept an `unknown` error value for local diagnosis. The logging +core must not inspect error classes, extract domain fields, traverse custom +causes, or maintain error-type registries. + +Local transports may use the original value. A transport that requires a wire +or persistent representation owns a small, explicitly lossy representation. For +an actual `Error`, it may preserve the standard text and stack directly. For +another value, use a small fallback such as `JSON.stringify`, with a final +string fallback if encoding throws. Keep a separately captured `originStack` +separate on the wire. Do not inspect domain fields or reconstruct an error +instance on the receiving side. + +If a known diagnostic value matters, the caller that knows its meaning should +pass it explicitly in context. Adding a new application error type must not +require editing logging infrastructure. + +## Do Not Promise Automatic Secret Cleanup + +Do not pass passwords, tokens, cookies, private keys, raw authorization +payloads, or unnecessary personal data to the logger. + +Do not invent a recursive sanitizer and rely on it to make unsafe logging safe. +If a repository or telemetry SDK already provides a proven boundary safeguard, +preserve it as defense in depth without turning it into a general application +error-conversion pipeline. + +## Keep Observability Concerns Distinct + +- Sentry-style breadcrumbs may be implemented as a logging transport. +- Decide whether `logger.error` means a diagnostic error or an incident-worthy + error. Do not automatically capture every error unless the repository makes + that contract explicit. +- When an incident provider is a logger transport, emit one eligible record and + let normal fan-out reach local persistence and the provider. Do not call the + provider separately from the same `reportError` flow. +- Stable typed application events may travel through the logging pipeline when + the facade and record distinguish them from ordinary logs. +- Metrics, timings, and analytics events must not be derived from human log + messages or an `info` level alone. +- Provider session control such as initialization, user identity, consent, and + reset is not a log transport and remains in the provider integration. +- User feedback is UI behavior, not a logging transport. + +## Verify The Result + +Before finishing logging work, verify that: + +- feature code imports only the configured logger facade; +- child scope and event context remain distinct; +- explicit application events remain distinguishable from ordinary logs; +- queued records cannot change when the caller later mutates its scope or + context object; +- messages are stable and dynamic values are structured; +- transports are registered once and fail independently; +- asynchronous transports have bounded queues and a flush policy; +- browser or Storybook execution does not instantiate a native transport when + its bridge is absent; +- renderer persistence crosses the established native boundary; +- the host preserves the renderer record rather than re-logging it; +- an error stack and a separately captured origin stack remain distinct; +- origin capture happens before async, queue, worker, or RPC boundaries; +- persisted and remote production stacks are symbolicated against artifacts + from the exact application release or update; +- existing persisted-log schemas remain readable or have an explicit migration; +- infrastructure observers can suppress misleading automatic origin capture; +- transport failures cannot recurse through the logger; +- no new normalization, sanitizer, or error-type registry was introduced; +- sensitive values are absent from records and transport payloads. + +## Related Skills + +- Native RPC contracts, handlers, renderer clients, and host registration -> + **native-integration**. +- Placement of the logging package, platform entrypoints, and local helpers -> + **file-structure**. +- Query and mutation ownership remains in **api-integration**; logging a request + does not move cache or error-handling responsibilities into this skill. +- Catch boundaries, reporting policy, retries, cancellation, and typed error + outcomes → **error-handling**. +- Error, success, warning, fallback, and recovery presentation → + **user-feedback**. diff --git a/.agents/skills/logging/agents/openai.yaml b/.agents/skills/logging/agents/openai.yaml new file mode 100644 index 0000000..53896fb --- /dev/null +++ b/.agents/skills/logging/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Logging" + short_description: "Structured cross-platform application logging" + default_prompt: "Use $logging to design or update structured logging across web, React Native, Electron renderer, and Electron main runtimes." diff --git a/.agents/skills/logging/references/facade-and-records.md b/.agents/skills/logging/references/facade-and-records.md new file mode 100644 index 0000000..baf2479 --- /dev/null +++ b/.agents/skills/logging/references/facade-and-records.md @@ -0,0 +1,368 @@ +# Logger Facade And Records + +Use this reference when defining or changing the public logger, record shape, +child behavior, message conventions, levels, or error argument. + +## Contents + +- [Minimal Contract](#minimal-contract) +- [Root And Child Loggers](#root-and-child-loggers) +- [Scope And Context](#scope-and-context) +- [Stable Messages](#stable-messages) +- [Explicit Application Events](#explicit-application-events) +- [Level Semantics](#level-semantics) +- [Error Values](#error-values) +- [Diagnostic Origins](#diagnostic-origins) +- [Transport Dispatch](#transport-dispatch) + +## Minimal Contract + +Adapt names to the repository, but preserve the separation between stable scope +and event-specific context: + +```ts +type LogLevel = "debug" | "info" | "warn" | "error" +type LogKind = "log" | "event" + +type LogScope = Record<string, unknown> +type LogContext = Record<string, unknown> + +interface LogRecord { + timestamp: number + kind: LogKind + level: LogLevel + message: string + scope: LogScope + context?: LogContext + error?: unknown + originStack?: string +} + +interface Logger { + debug(message: string, context?: LogContext): void + info(message: string, context?: LogContext): void + warn(message: string, context?: LogContext): void + error(message: string, error?: unknown, context?: LogContext): void + event(name: string, context?: LogContext): void + child(scope: LogScope): Logger +} +``` + +This is illustrative rather than a mandatory literal type. Reuse an established +contract when it represents the same behavior. + +## Root And Child Loggers + +Create one root logger for each runtime: + +```ts +const logging = createLogging({ + scope: { + runtime: "electron-renderer", + applicationVersion, + sessionId, + }, + transports: [consoleTransport], +}) + +export const logger = logging.logger +``` + +Runtime metadata belongs to the root configuration. Feature code should not +repeat it. + +A child creates another immutable facade with merged scope: + +```ts +const moduleLog = logger.child({ + module: "projects", +}) + +const operationLog = moduleLog.child({ + operation: "open-project", + requestId, +}) +``` + +Creating a child must not create another transport registry, queue, timer, or +file handle. All descendants dispatch through the same runtime logging +instance. + +Snapshot the supplied root and child scope objects shallowly. Otherwise a +caller can mutate an object after creating the logger and silently rewrite the +scope observed by a delayed transport: + +```ts +const scope = { module: "projects" } +const log = logger.child(scope) + +scope.module = "unrelated" // Must not alter `log`. +``` + +Use a child when several records share the value: + +```ts +const syncLog = logger.child({ + module: "sync", + operationId, +}) + +syncLog.info("Synchronization started") +syncLog.debug("Synchronization batch received", { itemCount }) +syncLog.info("Synchronization completed", { durationMs }) +``` + +Do not create a child merely to emit one line: + +```ts +logger.info("Project opened", { projectId }) +``` + +## Scope And Context + +Typical scope fields include: + +- runtime; +- module or capability; +- component or process; +- operation; +- request, correlation, session, or task identifier. + +Keep scope values small and suitable for transport. Do not attach service +clients, stores, React values, request objects, whole entities, or arbitrary +class instances. + +Context belongs to one record: + +```ts +log.info("Workspace loaded", { + workspaceId, + projectCount, + durationMs, +}) +``` + +The logging core should merge scope and preserve context without walking, +normalizing, or enriching their values. Take a shallow snapshot of event +context when creating the record because an asynchronous transport may flush +later. This only protects the top-level record fields; do not deep-clone nested +objects. + +## Stable Messages + +Keep messages readable and stable: + +```ts +// Avoid: every id produces a different message. +log.info(`Workspace ${workspaceId} loaded in ${durationMs}ms`) + +// Prefer: the message groups naturally; values remain queryable. +log.info("Workspace loaded", { + workspaceId, + durationMs, +}) +``` + +Do not require a second machine event name for every log. Metrics and analytics +events must not be inferred by parsing log messages. + +## Explicit Application Events + +When an application sends stable domain events to OpenReplay or another +telemetry provider, the configured logger may expose a distinct `event` +operation: + +```ts +const log = logger.child({ module: "mtls" }) + +log.event("mtls_provision", { + method: "jwt", +}) +``` + +The resulting record must retain `kind: "event"` across asynchronous queues and +renderer-to-host boundaries. Console and file transports may store it alongside +ordinary logs. A telemetry transport can forward it without guessing from the +message or level. + +Do not forward every `info` record as analytics. That silently changes +diagnostic text into a remote data contract, increases telemetry volume, and +makes harmless message edits alter dashboards. + +## Level Semantics + +### Debug + +Use for high-volume details needed while investigating behavior: + +```ts +log.debug("Request batch scheduled", { + requestCount, + delayMs, +}) +``` + +Debug records may be disabled, sampled, or dropped by a transport. + +### Info + +Use for meaningful lifecycle and business-operation milestones: + +```ts +log.info("Workspace opened", { workspaceId }) +``` + +Do not log every render, selector, helper call, or successful network request. + +### Warn + +Use when the application continued despite unexpected or degraded behavior: + +```ts +log.warn("Cached configuration unavailable; defaults applied", { + configurationId, +}) +``` + +A warning should communicate what degraded or what fallback was selected. + +### Error + +Use for a failed operation or diagnostic error event: + +```ts +log.error("Workspace synchronization failed", error, { + workspaceId, + operationId, +}) +``` + +This call records a failure. It does not mean the failure was handled, shown to +the user, retried, or reported as an incident. + +## Error Values + +Keep the public error parameter `unknown`. Do not require errors to inherit from +one application base class. + +The core logger passes the value to local transports unchanged. If a transport +must encode it, keep the fallback local and deliberately small: + +```ts +function stringifyError(error: unknown): string | undefined { + if (error === undefined) { + return undefined + } + + try { + const serialized = JSON.stringify(error) + return serialized && serialized !== "{}" ? serialized : String(error) + } catch { + try { + return String(error) + } catch { + return "[unserializable error]" + } + } +} +``` + +`JSON.stringify(new Error("failed"))` commonly produces `{}` because standard +error fields are not enumerable. Falling back to `String(error)` in that case +preserves the basic message without introducing a universal serializer, +middleware chain, or error registry. + +When a field is important and the caller knows its semantics, provide it +explicitly: + +```ts +log.error("Request failed", error, { + requestId, + status, +}) +``` + +Do not teach the logger to discover `status`, `details`, response bodies, or +domain-specific properties from arbitrary errors. + +## Diagnostic Origins + +An optional `originStack` belongs to the record itself, not ordinary event +context. It describes where the facade was called or where later asynchronous +work was initiated. + +Capture it in the public method before dispatch: + +```ts +function error( + message: string, + error?: unknown, + context?: LogContext, + options?: LogCallOptions, +): void { + const captureOrigin = + options?.captureOrigin ?? captureOriginForConfiguredPolicy("error") + const originStack = + options?.originStack ?? + (captureOrigin ? captureTraceOrigin().stack : undefined) + + dispatch({ + timestamp: Date.now(), + kind: "log", + level: "error", + message, + scope, + ...(context === undefined ? {} : { context: { ...context } }), + ...(error === undefined ? {} : { error }), + ...(originStack === undefined ? {} : { originStack }), + }) +} +``` + +Do not capture inside `dispatch()` or `transport.write()`: the first meaningful +frame will already be logging infrastructure. Do not concatenate the result +with `error.stack`. + +Do not hide the capture in a generic private emitter or origin resolver either: +that helper becomes the leading frame. An observer with no real operation +origin passes `{ captureOrigin: false }`; ordinary feature calls rely on the +configured runtime policy. + +Keep capture policy at bootstrap so development can retain more detail than a +high-volume production runtime. Read `trace-origins.md` for portable capture, +async origins, native boundaries, and source-map requirements. + +## Transport Dispatch + +The core behavior can remain conceptually small: + +```ts +function emit( + level: LogLevel, + message: string, + context?: LogContext, + error?: unknown, +): void { + const record: LogRecord = { + timestamp: Date.now(), + kind: "log", + level, + message, + scope, + ...(context === undefined ? {} : { context: { ...context } }), + ...(error === undefined ? {} : { error }), + } + + for (const transport of transports) { + try { + transport.write(record) + } catch { + reportTransportFailureOnce() + } + } +} +``` + +Do not copy this literally when the repository already owns the facade. The +important properties are one record, one fan-out step, independent transports, +and no generic processor chain between them. diff --git a/.agents/skills/logging/references/platform-transports.md b/.agents/skills/logging/references/platform-transports.md new file mode 100644 index 0000000..f23b5ea --- /dev/null +++ b/.agents/skills/logging/references/platform-transports.md @@ -0,0 +1,339 @@ +# Platform Transports + +Use this reference when adding or changing transport registration, browser or +native persistence, Electron or Electrobun renderer-to-host delivery, batching, +flushing, or teardown. + +## Contents + +- [Transport Contract](#transport-contract) +- [Early Logging](#early-logging) +- [Transport Failure](#transport-failure) +- [Web](#web) +- [React Native](#react-native) +- [Electron And Electrobun](#electron-and-electrobun) +- [File Persistence](#file-persistence) +- [Stack And Origin Preservation](#stack-and-origin-preservation) +- [Telemetry](#telemetry) +- [Tests](#tests) + +## Transport Contract + +Keep the caller-facing logger synchronous. An asynchronous transport owns its +queue and lifecycle: + +```ts +interface LogTransport { + write(record: LogRecord): void + flush?(): Promise<void> + dispose?(): Promise<void> +} +``` + +Register transports at the runtime composition root: + +```ts +const logging = createLogging({ + scope: { + runtime: "web", + }, + transports: [createConsoleTransport()], +}) + +const removePersistence = logging.addTransport( + createBrowserPersistenceTransport(), +) +``` + +`addTransport()` should return a removal function or another clear lifecycle +handle. Prevent duplicate registration during HMR, repeated bootstrap, tests, +or remounting. + +## Early Logging + +Make the configured facade importable before optional services initialize. +Keep a console transport available from the start. + +Do not add an unbounded hidden queue for records emitted before persistence or +RPC is ready. It is acceptable for early records to reach only the console. + +If an application must retain early records, make that bounded bootstrap buffer +an explicit project decision with a clear ownership and flush point. + +## Transport Failure + +One transport failure must not: + +- throw through the logger call; +- prevent another transport from receiving the record; +- recursively invoke the same logger; +- retry forever; +- grow an unbounded queue. + +A transport may emit one guarded, rate-limited `console.warn` describing its +own failure. Do not route that warning through the logger it is servicing. + +## Web + +A web runtime may compose: + +- a developer console transport; +- IndexedDB or another browser persistence transport; +- a remote logging or telemetry transport. + +Browser persistence should own its batching, retention, querying, export, and +cleanup behavior. Do not place IndexedDB knowledge in the logger facade. + +Treat an existing persisted schema as a compatibility contract. If the common +record changes from fields such as `t` and `tags` to `timestamp`, `scope`, and +`context`, adapt new records inside the persistence transport or perform an +explicit database migration. Do not silently make old IndexedDB records +unreadable. + +Treat page shutdown flushes as best effort. Use the repository's established +page lifecycle integration rather than claiming that every queued record is +durable. + +## React Native + +React Native usually presents one JavaScript runtime to application code, even +when persistence ultimately crosses a native module. + +Hide that bridge inside the React Native transport: + +```text +application logger + -> React Native transport + -> native logging or file capability +``` + +Feature code still imports the common configured facade. It must not call the +native persistence module directly for ordinary logging. + +Flush buffered records on the project's established application-background or +shutdown lifecycle when useful, without blocking user-visible transitions. + +Verify the filesystem API's relocation semantics before implementing rotation. +For example, APIs in which `move` mutates the source `File` object's URI can +accidentally make subsequent writes target the backup. Copying to the backup +and truncating the current file may be safer for that API. This is a +transport-specific decision, not logger-core behavior. + +## Electron And Electrobun + +Treat the privileged host and renderer as separate runtimes: + +```text +renderer feature + -> renderer logger facade + -> renderer console transport + -> bounded RPC transport + -> host logging handler + -> host-owned file transport + +host feature + -> host logger facade + -> host console transport + -> host-owned file transport +``` + +The host owns file paths, file creation, rotation, retention, reading, and +export. Do not grant the renderer filesystem access merely for logging. + +Use the repository's existing native-RPC foundation. The RPC client belongs +inside the renderer transport; feature modules do not import it. + +Only install the native transport when the bridge is actually available. +Storybook, browser previews, tests, or SSR may import the same configured +facade without a desktop host; those runtimes should retain console logging +without repeatedly failing native requests. + +### Batch Contract + +Prefer one ingestion method carrying a batch of structured records: + +```ts +const loggingMethods = { + writeBatch: "logging.writeBatch", +} as const + +interface WriteLogBatchParams { + records: RendererLogRecord[] +} +``` + +Reuse the repository's contract and validation system. Do not hand-maintain +parallel request shapes when a source of truth already exists. + +Register the method as ordinary native infrastructure. Do not expose log +ingestion to an agent or user-facing capability catalog unless a separate, +explicit product requirement calls for it. + +The wire record should preserve `originStack` and, for an actual error, a +minimal `errorText` and `errorStack`. Keep them as separate fields. Do not send +an `Error` instance, concatenate stacks, or introduce a generic domain error +serializer. + +The transport may apply the smallest wire-only representation change required +by the RPC implementation. Do not place that conversion in the core logger or +reuse it as a general application error model. + +### Renderer Queue + +The renderer RPC transport should: + +- preserve record order within a batch; +- cap its queue; +- flush on a short interval or batch-size threshold; +- avoid one RPC request per ordinary record; +- prefer dropping old `debug` records before more important records when full; +- expose a best-effort `flush()`; +- stop timers and reject new persistence work after `dispose()`; +- keep console logging available when RPC is unavailable. + +Exact batch sizes and intervals depend on the application. Keep them +configurable beside the transport rather than spreading constants through +feature code. + +### Host Ingestion + +The host handler receives records from the renderer and sends them directly to +the host-owned persistence sink. + +Do not call the host root logger again: + +```ts +// Avoid: creates a host record from a renderer record. +mainLogger.info(record.message, record.context) + +// Prefer: preserve the received renderer record. +fileTransport.write(record) +``` + +Re-logging can: + +- replace the renderer runtime with the host runtime; +- assign a second timestamp; +- duplicate console output; +- apply level filtering twice; +- create a loop when transports are composed incorrectly. + +The host's own application logs continue through its host root logger. + +### Infrastructure Failures + +Native RPC may itself need logging. Do not make successful delivery through the +same RPC channel the only way to diagnose its failure. + +Keep direct guarded console output available inside the logging and native-RPC +infrastructure. Avoid a cycle where an RPC failure logs through the failing RPC +transport indefinitely. + +## File Persistence + +The file transport owns: + +- record formatting, such as JSONL or readable text; +- file location; +- append and flush behavior; +- rotation and retention; +- file read/export capabilities; +- platform-specific filesystem errors. + +Reuse a public application-data path provider when one exists. Do not import a +different package's private runtime helper. When no public provider exists, +keep a minimal platform resolver inside the host transport or inject one from +the host composition root. + +Keep the stored representation stable enough for inspection, but do not turn +the file transport into a universal object or error serializer. + +Errors need a small boundary representation. Preserve the standard text and +stack of an actual `Error`; use the established small string fallback for other +values. Preserve an existing `originStack` independently. Scope and context +should already contain plain diagnostic values; if they are circular or +unsupported by the wire, dropping that record is preferable to adding a +recursive sanitizer to the shared logger. + +## Stack And Origin Preservation + +The transport must not invent a later origin: + +- a browser persistence transport keeps the facade-captured origin; +- a React Native native-file transport keeps the JavaScript error and origin + strings without depending on private LogBox or Metro APIs; +- a renderer RPC transport keeps renderer stacks; +- a desktop host writes received renderer stacks directly instead of replacing + them with handler or file-writer frames; +- a host-side failure remains a separate host error correlated by a safe request + identifier. + +Production persistence is useful only when the corresponding web, Node, +Hermes, or native source maps and symbols can be matched to the exact release +or OTA update. Read `trace-origins.md` for the capture helper, platform details, +symbolication, and verification matrix. + +## Telemetry + +A telemetry breadcrumb transport may receive ordinary structured records. +Apply the SDK's established filtering and lifecycle at that boundary. + +An application-event transport should receive only records explicitly marked +as events: + +```ts +const openReplayTransport: LogTransport = { + write(record) { + if (record.kind !== "event") return + + tracker.event(record.message, { + ...record.scope, + ...record.context, + level: record.level, + timestamp: new Date(record.timestamp).toISOString(), + }) + }, +} +``` + +Keep this transport in the provider integration package and register it at the +runtime composition root. Provider initialization, user identity, consent, and +reset remain explicit provider operations rather than logger methods. + +Do not automatically translate every `error` record into an exception incident. +Exception capture has different semantics and belongs to error reporting. + +Do not derive counters, timings, or analytics events from ordinary log messages +or levels. Use explicit typed instruments or event records. + +## Tests + +Use an in-memory transport to assert records without mocking the console: + +```ts +function createMemoryTransport() { + const records: LogRecord[] = [] + + return { + records, + write(record: LogRecord) { + records.push(record) + }, + } +} +``` + +Verify: + +- child scopes merge without mutating their parents; +- one method call creates one record; +- one broken transport does not block another; +- registration and removal are deterministic; +- queue limits and drop policy are enforced; +- flush sends the remaining batch; +- host ingestion preserves renderer metadata and timestamps. +- original error stacks remain unchanged after repeated transport delivery; +- origin stacks survive queues and renderer-to-host delivery as separate data; +- telemetry transports ignore ordinary records unless their policy explicitly + includes them; +- event identity survives any RPC or persistence boundary. diff --git a/.agents/skills/logging/references/proven-platform-patterns.md b/.agents/skills/logging/references/proven-platform-patterns.md new file mode 100644 index 0000000..b638c53 --- /dev/null +++ b/.agents/skills/logging/references/proven-platform-patterns.md @@ -0,0 +1,162 @@ +# Proven Platform Patterns + +Use these compact shapes when adapting the logging foundation to a concrete +client runtime. They are examples of the same contract, not four competing +logger designs. + +## Web + +Place a reusable facade in a package when several workspace modules or +applications can consume it. Keep browser persistence application-owned: + +```text +packages/logger + -> contract, factory, console transport + +apps/web/core/logger + -> configured web root + -> IndexedDB transport +``` + +The IndexedDB transport owns batching, retention, queries, export, and mapping +between the current common record and any legacy stored schema. + +New errors should receive the transport's minimal string fallback before JSONL +export. Preserve an actual error stack and any separately captured origin stack. +Other non-cloneable context still fails best effort; do not add a recursive +common serializer. + +## React Native + +In a single-application repository, prefer an application module over an +artificial workspace package: + +```text +core/logging/index.ts + -> facade and process-local root + +core/logging/transports/native-file.ts + -> buffer, JSONL, rotation, native filesystem +``` + +Initialize once before application features start. Register a best-effort flush +when the established app lifecycle leaves the active state. Skip the native +file transport on web. + +Keep the original `Error` for console and incident providers. Persist its +standard stack separately from a facade- or operation-captured origin. Release +and OTA-update traces require their matching Hermes source maps. Include stable +release correlation in the root scope, such as the application version, +runtime version, and update identifier; an OTA stack without its exact update +identity cannot be matched reliably to an artifact. + +Keep filesystem behavior inside the transport and test the installed API +version. In particular, determine whether move/rename operations mutate the +source object before using them for rotation. + +## Electron + +Use one package with explicit runtime entrypoints: + +```text +logger +├── index.ts +├── native/client.ts +├── native/main/index.ts +└── preload.ts +``` + +- `index.ts` is Electron-free. +- `native/client.ts` configures renderer console plus bounded batch RPC. +- `native/main/index.ts` configures main console plus file persistence and + registers `writeBatch`. +- `preload.ts` owns a console-only process-local root. + +The renderer transport should only be installed when the preload bridge exists, +so browser previews and Storybook remain valid consumers. The main handler +writes received records straight to the file transport, including renderer +error and origin stacks. + +Choose one incident policy. + +```ts +// Diagnostic logger and explicit incident reporter are separate. +log.error("Workspace load failed", error, { workspaceId }) +reporting.captureException(error) +``` + +Use that shape when some error logs are diagnostic-only. Ensure one owner calls +both operations once; do not add a second global observer for the same failure. + +Alternatively, a repository may define every error record as incident-worthy: + +```text +reportError(error) + -> one logger.error record + -> console and file transports + -> incident-provider transport +``` + +In that policy, the provider transport owns `captureException` and receives the +original local `Error`. Application reporting code must not also call the +provider SDK. If only selected errors are incidents, add an explicit record kind +or call option rather than deriving intent from text. + +When the renderer also uses OpenReplay or another session provider, keep its +event adapter in that provider package: + +```text +feature logger.event(...) + -> renderer logger + -> console and file transports + -> OpenReplay event transport +``` + +The provider transport always forwards explicit event records. Under the +repository's incident policy it may also forward eligible error records, +preserving the original `Error`, scope, context, and separate `originStack`. +Create a fallback `Error` only inside the provider boundary when its SDK +requires one and the record contains a non-Error value. + +Initialization, identity, consent, and reset remain provider control operations; +they are not log transports. + +## Electrobun + +Use the same package split, replacing Electron entrypoints with the repository's +Electrobun native-module conventions: + +```text +renderer logger + -> console + -> bounded writeBatch client + +Bun handler registry + -> validate batch + -> host file transport +``` + +Register `writeBatch` in the native handler/module registry. If the repository +also derives agent capabilities from handler definitions, leave logging +unexposed unless it has explicit capability metadata. + +The host file transport may resolve its own application-data location or +receive a public resolver from the composition root. Do not reach into another +package's private filesystem implementation. + +## Shared Proof Obligations + +Across all four variants, verify: + +- shallow scope and context snapshots; +- original local error identity before a boundary; +- boundary-local error text and standard stack without domain conversion; +- origin stacks kept separate from error stacks; +- bounded async queues and deterministic flush; +- independent transport failures; +- stable persisted schemas or explicit adapters; +- no native requests when the native bridge is absent; +- no host re-logging of renderer records. +- matching source maps or symbols for production releases and updates; +- explicit event identity preserved across transports; +- no accidental promotion of ordinary `info` logs into remote events. diff --git a/.agents/skills/logging/references/trace-origins.md b/.agents/skills/logging/references/trace-origins.md new file mode 100644 index 0000000..3cfc8cc --- /dev/null +++ b/.agents/skills/logging/references/trace-origins.md @@ -0,0 +1,308 @@ +# Trace Origins Across Client Runtimes + +Use this reference when logs or reported errors point to logger internals, +catch blocks, Query callbacks, queue flushes, RPC handlers, or minified bundles +instead of the code that initiated the operation. + +## Contents + +- [Keep Three Locations Distinct](#keep-three-locations-distinct) +- [Capture An Origin Portably](#capture-an-origin-portably) +- [Choose A Capture Policy](#choose-a-capture-policy) +- [Preserve Origins In Records](#preserve-origins-in-records) +- [Async And Framework Boundaries](#async-and-framework-boundaries) +- [Web](#web) +- [React Native](#react-native) +- [Electron And Electrobun](#electron-and-electrobun) +- [Production Symbolication](#production-symbolication) +- [Verification](#verification) + +## Keep Three Locations Distinct + +One diagnostic flow can contain three legitimate locations: + +1. **Failure stack**: where an `Error` was created or thrown. +2. **Operation origin**: where the caller initiated work that later crossed an + async, framework, worker, or RPC boundary. +3. **Observation location**: where a cache callback, reporter, transport, or + host received the result. + +The failure and operation origin are useful. The observation location is +usually infrastructure noise. Do not overwrite the first two with the third. + +For an ordinary log without an error, the operation origin is the logger call +site. For an error log, the original error stack remains primary and the logger +origin is supplemental. + +## Capture An Origin Portably + +`Error.stack` exists across common client engines, but its exact string format +and frame names differ. V8 exposes `Error.captureStackTrace`; Hermes and +JavaScriptCore compatibility must not be assumed. + +Use feature detection and keep the raw stack: + +```ts +export interface TraceOrigin { + readonly stack?: string +} + +type ErrorWithStack = { + stack?: string +} + +type ErrorConstructorWithCapture = ErrorConstructor & { + captureStackTrace?( + target: ErrorWithStack, + constructor?: (...args: never[]) => unknown, + ): void +} + +export function captureTraceOrigin(): TraceOrigin { + const target: ErrorWithStack = {} + const ErrorRuntime = Error as ErrorConstructorWithCapture + + if (typeof ErrorRuntime.captureStackTrace === "function") { + ErrorRuntime.captureStackTrace(target, captureTraceOrigin) + return { stack: target.stack } + } + + return { + stack: new Error("Operation origin").stack, + } +} +``` + +The fallback may retain the helper frame. That is better than deleting a fixed +number of lines and corrupting another engine's format. If a repository already +has a tested engine-specific frame filter, keep it at the presentation boundary, +not in the stored record. + +Capture before the discontinuity: + +```ts +const origin = captureTraceOrigin() + +queueMicrotask(() => { + runJob().catch(error => { + reportError(error, { originStack: origin.stack }) + }) +}) +``` + +Capturing inside `catch`, a queue flush, or an RPC handler only records that +observer. + +## Choose A Capture Policy + +Stack capture and source-map lookup have a cost. Do not add it blindly to every +high-volume production record. + +A practical starting policy is: + +- development: capture origins for every enabled level; +- production: capture for `warn`, `error`, and explicitly traced operations; +- error records: always preserve the original error object locally, regardless + of the origin policy; +- sampled or high-volume events: prefer stable scope and correlation identifiers + unless a measured investigation needs stacks. + +Make the policy configurable at the runtime composition root. Feature code must +not contain environment checks solely to decide whether a logger captures its +call site. + +The method-level options should also support an explicit opt-out: + +```ts +interface LogCallOptions { + readonly captureOrigin?: boolean + readonly originStack?: string +} +``` + +Use `captureOrigin: false` for infrastructure observers that have no genuine +earlier operation origin, such as a declarative Query cache callback, a global +error listener, an RPC ingestion handler, or a file writer. Their local stack +is an observation location, not a useful substitute for the caller. + +## Preserve Origins In Records + +Keep the record shape explicit: + +```ts +interface LogRecord { + readonly timestamp: number + readonly level: LogLevel + readonly message: string + readonly scope: LogFields + readonly context?: LogFields + readonly error?: unknown + readonly originStack?: string +} +``` + +Capture `originStack` at the public facade call, not inside `dispatch()` or a +transport. Allow an established reporter or operation wrapper to provide an +earlier origin captured before an async boundary. + +The distinction includes private facade helpers. If a public method delegates +automatic capture to a generic `emit()` or `resolveOrigin()` helper, that helper +becomes the leading frame. Capture directly while executing the public method, +then pass the raw stack into the private emitter. Do not trim the helper by a +fixed line count. + +Do not concatenate: + +```ts +// Avoid +error.stack += `\n--- called from ---\n${originStack}` +``` + +That mutates a potentially shared error, makes retries duplicate frames, and +produces a string that incident SDKs and source-map processors may parse +incorrectly. + +Local console and telemetry transports should receive the original `Error`. +At a serialization boundary, preserve only the standard diagnostic fields +needed by that boundary: + +```ts +interface WireLogRecord { + readonly errorText?: string + readonly errorStack?: string + readonly originStack?: string +} + +function toWireError(error: unknown) { + if (error instanceof Error) { + return { + errorText: `${error.name}: ${error.message}`, + errorStack: error.stack, + } + } + + return { + errorText: stringifyUnknownAtBoundary(error), + } +} +``` + +This is not a domain error serializer. Do not traverse causes, discover custom +properties, or maintain an error-class registry in logging infrastructure. +When extending a stored or wire schema, keep legacy fields readable until the +transport performs an explicit migration; new records can populate +`errorText`, `errorStack`, and `originStack` without rewriting old data. + +## Async And Framework Boundaries + +Modern engines can retain useful frames across some `await` chains, but timers, +event emitters, task queues, cache execution, workers, and RPC create real +discontinuities. Do not rely on one engine's current async-stack behavior as an +application contract. + +- Capture before scheduling a timer, background task, or queued callback when + the initiating caller matters. +- Give concurrent operations separate origins. Never store the latest origin in + one module-level or hook-level mutable variable. +- For TanStack Query, keep the rejection's original error. A stack captured + while defining a hook identifies registration, not necessarily the later + mutation invocation. A global MutationCache can capture synchronously in + `onMutate` and associate the origin with the Mutation instance; read the + TanStack Query reference below. +- For a React render failure, keep both the thrown error stack and React's + component stack; they answer different questions. +- Use safe operation or request identifiers to correlate logs across runtimes. + +Read `../../error-handling/references/tanstack-query.md` for reporting ownership +and mutation invocation details. + +## Web + +Pass an original `Error` as a value to the console transport instead of only +printing its message or preformatted stack string. Browser DevTools can then +inspect the actual error. + +The clickable location of a wrapped `console.*` call may still point to the +transport. Use the record's separately captured `originStack` when the facade +call site matters. Do not depend on DevTools-specific console formatting as the +only persisted diagnostic. + +Production bundles require source maps available to the chosen incident or log +inspection path. If public source maps are unacceptable, upload them privately +to the provider and remove or withhold deployed `.map` files according to the +build system's supported flow. + +## React Native + +Keep the original `Error` for the local console and incident SDK. Do not replace +it with `error.stack` before reporting; that turns the error into plain text and +can make the logger call look like the origin. + +React Native development tools and LogBox are development aids, not production +symbolication. Avoid private LogBox or Metro APIs for trimming or +symbolicating stacks inside application code. + +Hermes release stacks need the matching JavaScript/Hermes source maps. An OTA +update has a different JavaScript artifact from the embedded application build; +upload and identify maps for each update as well as each native build. + +## Electron And Electrobun + +Treat renderer and host stacks as separate runtime evidence. + +Renderer logging should send: + +- the renderer record and timestamp; +- renderer scope and safe correlation identifiers; +- minimal `errorText` and `errorStack`; +- the separately captured renderer `originStack`. + +The host persists those fields unchanged. It must not re-log the record and +replace the renderer origin with the ingestion handler. + +If a native handler itself fails, report the original failure in the host while +the host `Error` still exists. A renderer-facing RPC failure is a separate, +lossy contract and must not pretend to contain the host stack. Correlate both +sides with a safe request identifier when diagnosis needs the full path. + +For Electron main or another Node-based host, enable the runtime's supported +source-map integration before application modules load. Do not globally replace +`Error.prepareStackTrace` or rewrite stack strings unless the repository already +has a tested requirement for it. + +## Production Symbolication + +A captured stack is only an address list until it maps to the exact shipped +code. + +Verify: + +- source maps are generated for every minified or transpiled runtime; +- the release, build, distribution, and OTA update identifiers match the + uploaded artifacts; +- source maps are uploaded before using a synthetic production failure as a + test; +- private maps are not unintentionally published; +- a real test event resolves to original file, line, and function names; +- persisted raw stacks retain enough generated file, line, and column data for + later symbolication. + +Do not claim trace preservation is complete after a development-only console +test. + +## Verification + +Test at least: + +1. a synchronous thrown `Error`; +2. a caught and rethrown identical error; +3. a wrapper error with `cause`; +4. a rejection after an `await`; +5. a timer or queued task with a captured origin; +6. concurrent mutations or tasks with different origins; +7. a renderer record persisted by the host; +8. a host handler failure correlated with its renderer request; +9. a production or preview build symbolicated with its exact artifacts. + +Assert that the original error stack never gains appended sections after +retries, repeated logging, or transport delivery. diff --git a/.agents/skills/native-integration/SKILL.md b/.agents/skills/native-integration/SKILL.md new file mode 100644 index 0000000..f4460a0 --- /dev/null +++ b/.agents/skills/native-integration/SKILL.md @@ -0,0 +1,345 @@ +--- +name: native-integration +description: Use whenever work adds, changes, reviews, or debugs desktop native integration in Electron, Electrobun, or another host/webview runtime. Trigger for Electron main-process, preload, contextBridge, ipcMain/ipcRenderer, IPC channels, Electrobun BrowserView/Electroview RPC, native modules, handler definitions, typed renderer clients, method or event registries, capability metadata or catalogs, runtime validation schemas, host registration, filesystem or OS access, deep links, secure storage, updates, and exposing native operations to automation, agents, or MCP. +--- + +# Native Integration + +Treat Electron, Electrobun, and similar desktop runtimes as transport adapters +around the same modular native-RPC architecture: + +```text +shared contract + -> handler definitions + -> native feature module + -> central host registry + -> runtime transport + -> typed renderer client + +handler definitions + -> capability catalog + -> optional automation, agent, or MCP consumers +``` + +The renderer is sandboxed. The host owns privileged work. A typed, validated, +inspectable native registry is the boundary between them. + +First inspect the repository's existing native-RPC foundation and one complete +feature module. Extend that pattern. Do not introduce a parallel raw IPC system. + +## Start From A Feature Module, Not A Channel + +A native integration is a feature with a contract, handlers, metadata, and a +client. It is not merely an IPC string. + +Examples include: + +- filesystem and operating-system access; +- secure storage and keychain operations; +- windows, dialogs, notifications, and shell operations; +- deep links and application lifecycle; +- application updates; +- local services and native addons. + +Each feature owns its contract and runtime implementation. The application +composes features through one registry. Transport-specific wiring must not +become the owner of feature behavior. + +## Prefer Package-Owned Native Features + +If a native feature can be isolated behind a coherent contract, place it in +`packages/`. This is the default, not an optimization reserved for features +already shared by multiple applications. + +The package should own: + +- its shared contract and method registry; +- its handler definitions and host implementation; +- its typed renderer client; +- its runtime-specific entrypoints when required. + +The application should only compose and register the package. Keep a feature +inside an application only when it is genuinely inseparable from that +application. + +An illustrative package may expose surfaces such as: + +```text +packages/{capability}/ +└── src/ + ├── index.ts + └── native/ + ├── index.ts + ├── client.ts + ├── main/ + │ └── index.ts + └── bun/ + └── index.ts +``` + +This is not a mandatory literal tree. Use only the runtime entrypoints the +project needs. Apply **file-structure** recursively to decide exact placement, +capability subfolders, public entrypoints, and local internals. + +Shared and renderer entrypoints must never import host-only dependencies. + +## Define One Shared Contract + +Declare every callable method once in a feature-owned registry: + +```ts +export const nativeMethods = { + chooseDirectory: "workspace:choose-directory", + readMetadata: "workspace:read-metadata", +} as const; +``` + +The contract must provide: + +- a stable, feature-namespaced wire method; +- the request parameters for that method; +- the response for that method; +- runtime schemas for both request and response. + +Prefer deriving TypeScript types from the schemas or another existing source of +truth. Do not separately hand-maintain method strings, DTOs, schemas, host +signatures, and renderer signatures. + +Runtime validation of both parameters and responses is mandatory at the native +boundary. An exception is acceptable only when the established transport +already guarantees the exact runtime-validated contract or the value cannot +meaningfully be represented by the project's schema system. Document that +exception beside the definition. `void` input or output is still an explicit +contract, not an omitted one. + +Do not create a generic `types.ts` dump. Keep each contract with the capability +that owns it. + +## Define Inspectable Handlers + +Every request handler definition should carry enough information to register, +validate, execute, inspect, and expose it without reconstructing knowledge +elsewhere: + +- a stable name; +- its method from the shared registry; +- parameter and response schemas; +- an execution policy, including timeout behavior; +- a host-context factory or explicit dependencies; +- capability metadata. + +Conceptually: + +```ts +const chooseDirectory = defineNativeHandler({ + name: "chooseDirectory", + method: nativeMethods.chooseDirectory, + params: chooseDirectoryParams, + response: chooseDirectoryResponse, + execution: { timeoutMs: 30_000 }, + capability: { + id: nativeMethods.chooseDirectory, + title: "Choose workspace directory", + description: "Opens the native directory picker.", + exposure: false, + requiresApproval: true, + }, + createContext: createWorkspaceContext, + handle: async ({ params, context }) => { + // Privileged feature logic belongs here or in an injected service. + }, +}); +``` + +Names and helper signatures are illustrative. Reuse the repository's native-RPC +primitives instead of recreating this API. + +Validate at the boundary. Keep privileged logic in the handler or an injected +host service, never in preload code or renderer code. + +Use bounded timeouts by default. Disable a timeout only for an operation whose +lifecycle is legitimately open-ended, such as waiting for a native user prompt, +and make that decision explicit. + +## Make Capabilities Universal And Exposure Explicit + +Every registered handler must include capability metadata even when its only +current consumer is the renderer. + +At minimum, metadata should make these facts discoverable: + +- stable capability id; +- human-readable title and description; +- safety or effect classification used by the project; +- whether explicit approval is required; +- whether the capability may be exposed outside the ordinary renderer client. + +Exposure is opt-in. The default must be `false`, `none`, or the repository's +equivalent. Registration makes a handler callable by the trusted application +client; it does not automatically make it available to automation, agents, or +MCP. + +Derive the capability catalog from handler definitions and their schemas. Do +not maintain a second manual registry. Automation, an agent, MCP, a command +palette, or another future consumer may read the catalog, but none of them owns +the native architecture. + +Operations with destructive effects, external side effects, sensitive data, or +arbitrary native-tool execution should require approval according to the +project's policy. + +## Build A Native Feature Module + +A native module aggregates the feature boundary: + +- module name; +- method registry; +- request map; +- handler loader or handler map; +- handler definitions and capability metadata; +- typed renderer client. + +The module must not know which application will register it. Host-only handlers +may load lazily to keep native dependencies out of shared and renderer bundles. + +Registration should fail clearly for duplicate methods, missing handlers, or a +contract that cannot be validated. Do not silently allow incomplete modules. + +## Keep One Reviewable Host Registry + +The host application should have one central registry that composes all native +modules. Adding an isolated feature should require one obvious registry entry, +not edits across unrelated switches and transport files. + +The registry should be able to: + +- combine request contracts; +- load and dispatch handlers by method; +- enumerate handler definitions and capability metadata; +- detect duplicate or missing registrations. + +Register the complete host surface before the renderer or webview can call it. +Application-owned registry code is wiring only; feature logic remains in its +package. + +## Generate A Thin Renderer Client + +Build the renderer client from the same request map and method registry used by +the host. + +The renderer: + +- imports a client-safe entrypoint such as `native/client`; +- calls feature methods, not raw transport channels; +- never imports host entrypoints, native addons, or `node:*`; +- never repeats method strings or request/response types; +- guards native-only access when the project also runs in a browser, test, or + Storybook environment. + +Keep transport errors in their native form unless the project already defines a +specialized error registry or conversion flow. Follow that established flow +when it exists. Never invent a new normalization layer inside a feature client. + +## Adapt Only The Transport + +The feature module and contract remain conceptually the same across runtimes. +Only the adapter changes. + +| Runtime | Host adapter | Renderer boundary | +| --- | --- | --- | +| Electron | Register the combined request map through `ipcMain.handle` or the repository's wrapper | Expose one narrow preload bridge through `contextBridge`; the typed feature client invokes it | +| Electrobun | Register the combined schema and handlers through the project's BrowserView RPC wrapper | Construct the typed client through Electroview RPC or the repository's wrapper | + +For Electron, prefer one shared bridge surface over a new `window.*` global for +every feature. Preload forwards validated typed calls; it contains no feature +business logic. + +For Electrobun, keep Bun-only handlers behind the Bun runtime entrypoint and +webview-safe client code behind the client entrypoint. + +Follow the exact runtime API and versions already installed in the repository. +Do not make feature packages depend on transport details that belong in the +adapter. + +## Treat Requests And Events As Separate Contracts + +Request-response is the default for commands and queries: send typed parameters +and receive one typed result. + +Events are first-class for host-pushed information such as: + +- deep-link navigation; +- update or download progress; +- native lifecycle changes; +- long-running operation progress; +- notifications produced outside a renderer request. + +Define a typed event registry and payload map rather than scattering event +strings. Every renderer subscription must return an unsubscribe function that +removes the exact listener it registered. + +Do not emulate request-response with ad hoc event pairs. Do not emulate a native +event stream with polling or repeated invokes. + +How a component subscribes and manages its lifecycle remains a React concern; +follow **components**, including its strict guidance on avoiding `useEffect`. + +## Preserve The Privilege Boundary + +- The renderer never accesses the filesystem, OS, native addons, keychain, or + host lifecycle directly. +- Validate untrusted request parameters before privileged work and validate the + result before returning it across the boundary. +- Prefer narrow operations over arbitrary primitives: for example, a specific + trusted URL operation instead of unrestricted shell execution. +- Inject host context such as windows, services, paths, and application metadata + rather than importing ambient global state throughout handlers. +- Follow the project's existing logging and error policy. Do not leak secrets, + sensitive native details, or arbitrary host error payloads across the bridge. + +## Implementation Workflow + +1. Inspect the native-RPC foundation, central registry, and one complete native + feature already present in the repository. +2. Decide whether the feature can be isolated into a package. Prefer a package + whenever it can. +3. Define the method registry, request map, and runtime schemas. +4. Implement handler definitions with execution policy, host context, and + capability metadata. +5. Aggregate them into a native feature module. +6. Derive or build the typed renderer client from the same contract. +7. Add one explicit entry to the central host registry. +8. Add a typed event contract only when the host must push information. +9. Test contract validation, handler behavior, registry completeness, client + typing, and subscription cleanup as applicable. +10. Run the repository's focused formatting, linting, type-checking, tests, and + native build checks. + +## Avoid + +- scattered raw `ipcMain`, `ipcRenderer`, or RPC method strings; +- a separate preload global for every feature; +- duplicate request/response types on opposite sides of the boundary; +- handlers without parameter or response validation; +- registered handlers without capability metadata; +- exposing registered handlers to agents, MCP, or automation by default; +- large application-level switches that own feature dispatch; +- renderer imports from host runtime entrypoints; +- host imports leaking into shared or client bundles; +- keeping an isolatable native feature in an application instead of `packages/`; +- a generic native, handlers, or types dumping ground. + +## Related Skills + +- Exact package and feature-internal placement, visibility, and entrypoints → + **file-structure**. +- Native client errors and external-service access → **api-integration**. +- Secure persisted state consuming a native provider → **state-management**. +- Native deep links entering the route tree → **routing**. +- Host and renderer diagnostic delivery → **logging**. +- Native failure propagation, exception reporting, and runtime boundaries → + **error-handling**. +- Native-originated user notifications and recovery presentation → + **user-feedback**. +- Formatting, linting, type-checking, tests, and native build checks → + **code-quality**. diff --git a/.agents/skills/routing/SKILL.md b/.agents/skills/routing/SKILL.md new file mode 100644 index 0000000..e46a726 --- /dev/null +++ b/.agents/skills/routing/SKILL.md @@ -0,0 +1,330 @@ +--- +name: routing +description: Use whenever work adds, changes, moves, debugs, or reviews application routes, route or page directories, URL paths, path or search params, navigation, redirects, layouts, outlets, guards, loaders, route-tree composition, router context or history, deep-link mapping, not-found behavior, or routed Storybook/test harnesses. Also use when deciding where a route or page belongs, even if the requested change is described only as a file move or a new screen. +--- + +# Routing + +Model application navigation as one explicit, typed tree. Keep URL contracts, +route declarations, page components, and tree composition separate enough that +each has one owner and the import graph stays acyclic. + +## Inspect The Existing Router First + +Before changing routes: + +1. Find the router entrypoint, root route, top-level branches, guards, and + not-found handling. +2. Trace the concrete parent chain of the affected page. +3. Inspect how the project validates params/search, loads route data, lazy-loads + components, and constructs links. +4. Check platform constraints such as browser, hash, or memory history and any + deep-link adapter. +5. Preserve a coherent local router convention unless this task explicitly + corrects or migrates it. + +Do not infer routing from directory names alone. The router definition is the +runtime source of truth. + +## Keep The Route Tree At The Source Root + +Prefer one `routes/` directory at the application source root: + +```text +src/ +├── api/ +├── routes/ +│ ├── __root.tsx +│ ├── router.tsx +│ ├── guards.ts +│ ├── app/ +│ ├── auth/ +│ └── local-auth/ +└── ui/ +``` + +Do not nest this root under an application branch and produce shapes such as +`app/routes/app`. `routes/app`, `routes/auth`, and `routes/local-auth` are clear +siblings in one navigation tree. + +These directories represent routing scopes, not necessarily literal URL +segments. For example, `routes/app` may be a pathless authenticated layout with +an internal id, while `routes/auth` may own the real `/auth` segment. Choose +branch names for the boundary they own; define the URL explicitly in the route. + +If the framework mandates filesystem routing, adapt the filenames it requires +while preserving the ownership and composition rules in this skill. + +## Treat URLs As Resource Contracts + +- Use stable, resourceful paths: `/projects` and `/projects/$projectId`. +- Put resource identity in path params. +- Put filters, sorting, tabs, pagination, and view options in search params. +- Keep transient component state outside the URL only when it has no navigation, + sharing, refresh, or history meaning. +- Treat import/export formats and persisted files as data contracts, not routes. +- Define redirects deliberately; do not use them to conceal an incoherent tree. + +A page directory mirrors ownership in the route tree, but it does not create a +route by filesystem magic unless the selected router explicitly works that way. + +## Use Three Deliberate Directory Shapes + +### Layout or guard branch + +Use a route-owning branch when descendants share a layout, guard, or URL segment: + +```text +routes/app/pages/Projects/ +├── index.tsx # ProjectsLayout with Outlet +├── route.tsx # /projects +├── route.tree.ts # List route + Item subtree +├── components/ # shared by project routes +└── pages/ + ├── List/ + │ ├── index.tsx # project list page + │ └── route.tsx # /projects + └── Item/ + ├── index.tsx # ProjectLayout with Outlet + ├── route.tsx # /projects/$projectId + ├── route.tree.ts # index redirect + item subpages + ├── components/ # shared by one project's subpages + └── pages/ + ├── Overview/ + │ ├── index.tsx + │ └── route.tsx # /projects/$projectId/overview + └── Workspace/ + ├── index.tsx + └── route.tsx # /projects/$projectId/workspace +``` + +`Projects/index.tsx` is a layout because `List` and `Item` are its route +children. `Item/index.tsx` repeats the same role one level deeper because +`Overview` and `Workspace` are its children. The pattern is recursive: any leaf +may become a layout boundary when it gains real child pages. + +Use contextual page names inside an established owner. `Projects/pages/Item` +means the selected project route; keep `ProjectItem` for a list row/card +component and `projectId` for the route parameter. Name rendered components by +their UI role, such as `ProjectsLayout`, `ProjectListPage`, `ProjectLayout`, and +`ProjectOverviewPage`. + +### Page with direct subpages + +When a page is not a resource collection and has no selected-id level, omit the +artificial `List` and `Item` layers: + +```text +routes/app/pages/Project/ +├── index.tsx # ProjectLayout with Outlet +├── route.tsx # /project + explicit index route +├── route.tree.ts +└── pages/ + ├── Overview/ + │ ├── index.tsx + │ └── route.tsx # /project/overview + ├── Workspace/ + │ ├── index.tsx + │ └── route.tsx # /project/workspace + └── Settings/ + ├── index.tsx + └── route.tsx # /project/settings +``` + +Define what happens at `/project` explicitly. It may redirect to `Overview`, +`Workspace`, `Settings`, or another existing child, but there is no universal +default: choose the destination at this boundary from the product's business +logic. Never infer it from child order or silently treat the first registered +route as the default. + +Keep the redirect local and visible: + +- use an owned index route declaration when the decision is available from + router context, a guard, a loader, or static product policy; +- render a named index-forwarder component when the decision requires + render-time state or hooks; +- let `route.tree.ts` register that index route with the subpages, but keep the + redirect decision out of tree-composition code; +- keep the ordinary layout component focused on shared UI and its `Outlet`. + +### Terminal page without subpages + +Treat `Overview`, `Workspace`, and `Settings` in the preceding example as leaf +pages: each is a terminal route with no child routes, `Outlet`, or +`route.tree.ts`. A leaf page normally owns: + +- `index.tsx`: the page component only; +- `route.tsx`: the route declaration, schema, guard/loader when leaf-specific, + and lazy component binding. + +Private page components, hooks, and other implementation go inward under the +same page boundary according to **file-structure**. When a leaf gains real +subpages, promote it to the layout-branch shape and apply the same structure +recursively. + +For example, keep components used only by `Overview` inside that page: + +```text +Overview/ +├── index.tsx +├── route.tsx +└── components/ + ├── ProjectSummary.tsx + └── ActivityPanel/ + ├── index.tsx + └── components/ + └── ActivityRow.tsx +``` + +`routing` owns the `Overview` page boundary. **file-structure** owns everything +inside it: place each component, hook, schema, or other implementation at the +narrowest boundary containing all of its consumers, and repeat that rule +recursively. + +### Structural group without a route + +When sibling pages need grouping but share no URL segment, guard, or layout, do +not invent a wrapper route: + +```text +routes/auth/pages/PasswordRecovery/ +├── route.tree.ts +└── pages/ + ├── RequestReset/ + │ ├── index.tsx + │ └── route.tsx + └── ResetPassword/ + ├── index.tsx + └── route.tsx +``` + +Its `route.tree.ts` exports the sibling route collection for the nearest real +parent to compose. A directory is not entitled to a route merely because it +exists. + +## Separate Declaration, Rendering, And Composition + +Give each routing file one job: + +- `route.tsx` declares only routes owned by that boundary. It imports its + concrete parent and lazy-loads the page/layout component. It never imports or + assembles descendant routes. +- `index.tsx` renders only the page or layout. A layout renders an `Outlet`; a + leaf renders its screen. It does not assemble the route tree. +- `route.tree.ts` imports sibling/descendant route objects and composes them with + their parent. It contains no screen UI, schemas, data lookup, or route-specific + branching. +- `router.tsx` attaches top-level branch trees to the root and owns global router + configuration. + +This keeps the structural import graph flowing child declaration -> concrete +parent declaration, then composition -> children. Parent declarations never +import their children, so page components can safely import their own route +object for typed hooks. + +Do not use barrels to aggregate routes. Import the exact `route` or `route.tree` +module so parentage and ownership remain visible. + +## Assemble Recursively + +Compose each subtree at the narrowest boundary that owns all of its children, +then pass one tree or a small route collection upward: + +```text +router.tsx +└── app/route.tree.ts + └── Projects/route.tree.ts + ├── List/route.tsx + └── Item/route.tree.ts + ├── route.tsx # optional item index redirect + ├── Overview/route.tsx + └── Workspace/route.tsx +``` + +Do not centralize every leaf in `router.tsx`. Do not declare child paths in a +parent component. Do not widen typed route collections to a generic route type +merely to make composition compile; preserve inferred route identities. + +Routing-specific placement is authoritative over **file-structure** for route +branches, pages, declarations, and tree assembly. Apply **file-structure** +recursively inside the selected page or layout boundary. + +## Put Behavior At The Narrowest Route Boundary + +- Put a guard on the nearest ancestor whose entire subtree shares the rule. +- Put a loader on the route that owns the navigation dependency. Reuse the + project's API query definitions and cache instead of building a second fetch + or cache policy inside the loader. +- Put a search schema beside the leaf that reads it. +- When sibling leaves consume the same search contract, put it in a small module + at their nearest shared boundary; do not force it into a parent declaration + that would create a parent-child import cycle. +- Keep global router context and history selection at the router root. +- Treat platform history choices as adapters: browser history is not universally + correct, and hash history is not universally correct. + +Reuse the project's established error, auth, preload, cache, and redirect flows. +Do not invent a parallel routing policy inside one page. + +## Read Route State Through The Route Contract + +Prefer the route object's typed params/search hooks when the router supports +them. Avoid handwritten route ids, string casts, and generic APIs that discard +which route owns a value. + +Pathless layouts can contribute internal ids without contributing URL segments. +Therefore an internal route id may differ from the visible path. Code and routed +test harnesses must preserve that distinction. + +Use a loose/non-strict route-state read only for genuinely route-agnostic shared +behavior, and accept the reduced per-route typing intentionally. + +## Navigate Semantically + +- Use the router's `Link` for user-initiated navigation so accessibility, + history, preloading, and modifier-click behavior remain intact. +- Use programmatic navigation for event outcomes and side-effect redirects such + as successful submission, authentication changes, or guard resolution. +- Construct destinations with typed `to`, `params`, and `search` values rather + than concatenating URL strings. +- Keep sidebar/header navigation separate from route registration. A valid route + does not automatically belong in primary navigation. +- Map platform deep links into the same route contracts. Keep transport and + operating-system event handling in the platform integration boundary instead + of creating a second navigation tree. + +## Preserve Real Routes In Tests And Stories + +Pages that read route-owned params or search values need an active match with +the same effective route id and parent chain. A flat memory router around the +component is insufficient when pathless ancestors contribute ids. + +Build the smallest parallel route chain that preserves the production ids, then +provide page chrome as a shell. Do not weaken production route typing or add +fallback params merely to make a story render. + +## Verify The Contract + +After a routing change: + +1. Search for stale paths, route ids, imports, links, redirects, deep-link + mappings, and navigation entries. +2. Typecheck the registered route tree without generic widening or casts. +3. Exercise direct entry, refresh, back/forward history, links, and + programmatic redirects. +4. Verify valid and invalid path/search params, guards, loaders, and not-found + behavior. +5. Run affected routed tests and stories, then the repository's required + lint/tests/build. + +Read [references/tanstack-router.md](references/tanstack-router.md) when the +project uses TanStack Router, when pathless layout ids are involved, or when a +routed Storybook/test harness must reproduce production route identity. + +## Skill Boundaries + +- Route loader queries and cache ownership → **api-integration**. +- Page and layout component implementation → **components**. +- Non-routing placement inside a page boundary → **file-structure**. +- Native deep-link transport and event handling → **native-integration** or + the project's platform integration skill. diff --git a/.agents/skills/routing/agents/openai.yaml b/.agents/skills/routing/agents/openai.yaml new file mode 100644 index 0000000..706ce38 --- /dev/null +++ b/.agents/skills/routing/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Application Routing" + short_description: "Own route contracts, page trees, and navigation" + default_prompt: "Use $routing to design or change application routes, page hierarchy, guards, params, navigation, or route-tree composition." diff --git a/.agents/skills/routing/references/tanstack-router.md b/.agents/skills/routing/references/tanstack-router.md new file mode 100644 index 0000000..aac46e1 --- /dev/null +++ b/.agents/skills/routing/references/tanstack-router.md @@ -0,0 +1,343 @@ +# TanStack Router Patterns + +Use these examples only when the project uses TanStack Router. The directory and +ownership rules remain applicable with other routers, but their APIs will differ. + +## Contents + +- Root and top-level branches +- Pathless guarded branches +- Recursive route layouts +- Direct subpage layouts and landing redirects +- Typed leaf state +- Recursive composition +- Shared search contracts +- Routed Storybook and tests + +## Root And Top-Level Branches + +Keep the root route, router creation, and branch assembly at `src/routes/`: + +```tsx +// routes/__root.tsx +export const rootRoute = createRootRouteWithContext<RouterContext>()({ + component: RootLayout, +}) + +// routes/router.tsx +const routeTree = rootRoute.addChildren([appTree, authTree, catchAllRoute]) + +export const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} +``` + +The exact router options are project decisions. For example, a desktop +application loaded from a file protocol may need hash history, while a hosted +web application normally uses browser history. Copy the boundary, not another +project's platform choice. + +## Pathless Guarded Branch + +An authenticated application branch may be pathless: + +```tsx +// routes/app/route.tsx +export const appLayoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: 'app', + beforeLoad: requireAuthenticated, + component: lazyRouteComponent(() => import('./index')), +}) + +export const appIndexRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/', + component: lazyRouteComponent(() => import('./pages/Home')), +}) +``` + +```tsx +// routes/app/index.tsx +export default function AppLayout() { + return ( + <AppShell> + <Outlet /> + </AppShell> + ) +} +``` + +The `app` id participates in internal route identity but not in the visible URL. +Do not assume the route id of `/projects/$projectId` is equal to that visible +path when it descends from the pathless branch. + +## Recursive Route Layouts + +Give `/projects` a layout route when both its list and selected item are child +routes: + +```tsx +// routes/app/pages/Projects/route.tsx +export const projectsRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/projects', + component: lazyRouteComponent(() => import('./index')), +}) + +// routes/app/pages/Projects/pages/List/route.tsx +export const Route = createRoute({ + getParentRoute: () => projectsRoute, + path: '/', + component: lazyRouteComponent(() => import('./index')), +}) + +// routes/app/pages/Projects/pages/Item/route.tsx +export const projectItemRoute = createRoute({ + getParentRoute: () => projectsRoute, + path: '$projectId', + component: lazyRouteComponent(() => import('./index')), +}) + +export const projectItemIndexRoute = createRoute({ + getParentRoute: () => projectItemRoute, + path: '/', + beforeLoad: ({ params }) => { + throw redirect({ + to: '/projects/$projectId/overview', + params, + }) + }, +}) +``` + +```tsx +// routes/app/pages/Projects/index.tsx +export default function ProjectsLayout() { + return <Outlet /> +} + +// routes/app/pages/Projects/pages/Item/index.tsx +import { projectItemRoute } from './route' + +export default function ProjectLayout() { + const { projectId } = projectItemRoute.useParams() + + return ( + <ProjectProvider projectId={projectId}> + <Outlet /> + </ProjectProvider> + ) +} +``` + +`Projects` owns the `/projects` segment and shared project-area layout. `List` +is its index child. `Item` owns `$projectId` and becomes another layout because +it has `Overview` and `Workspace` children. If `Item` has no child pages, omit +its `route.tree.ts` and `pages/`; its `index.tsx` is then the leaf screen. + +An item index may render `Overview` at path `/`, or redirect +`/projects/$projectId` to an explicit `overview` child. Choose one canonical URL +and define that index behavior in the item route boundary. + +## Direct Subpage Layouts And Landing Redirects + +Omit collection/item layers when the route is an ordinary page with direct +subpages: + +```tsx +// routes/app/pages/Project/route.tsx +export const projectRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/project', + component: lazyRouteComponent(() => import('./index')), +}) + +export const projectIndexRoute = createRoute({ + getParentRoute: () => projectRoute, + path: '/', + component: lazyRouteComponent( + () => import('./index'), + 'ProjectIndexForwarder', + ), +}) +``` + +```tsx +// routes/app/pages/Project/index.tsx +export default function ProjectLayout() { + return <Outlet /> +} + +export function ProjectIndexForwarder() { + const canOpenWorkspace = useCanOpenWorkspace() + + return ( + <Navigate + replace + to={canOpenWorkspace ? '/project/workspace' : '/project/overview'} + /> + ) +} +``` + +```tsx +// routes/app/pages/Project/route.tree.ts +import { Route as projectOverviewRoute } from './pages/Overview/route' +import { Route as projectSettingsRoute } from './pages/Settings/route' +import { Route as projectWorkspaceRoute } from './pages/Workspace/route' +import { projectIndexRoute, projectRoute } from './route' + +export const projectTree = projectRoute.addChildren([ + projectIndexRoute, + projectOverviewRoute, + projectWorkspaceRoute, + projectSettingsRoute, +]) +``` + +The index route is mandatory when the layout route itself has no screen. The +destination is not prescribed: select an existing child from local business +rules. Use an inline redirect or route lifecycle function when the choice is +static or available before render. Use a named index-forwarder component when +the choice requires React state or hooks. In both cases, keep the redirect +explicit and keep `route.tree.ts` limited to composition. + +## Typed Leaf State + +Keep each leaf route object at module level and let its component import it: + +```tsx +// routes/app/pages/Projects/pages/Item/pages/Overview/route.tsx +const searchSchema = z.object({ + tab: z.enum(['summary', 'history']).optional().catch('summary'), +}) + +export const Route = createRoute({ + getParentRoute: () => projectItemRoute, + path: 'overview', + validateSearch: searchSchema, + component: lazyRouteComponent(() => import('./index')), +}) +``` + +```tsx +// routes/app/pages/Projects/pages/Item/pages/Overview/index.tsx +import { Route } from './route' + +export default function ProjectOverviewPage() { + const { projectId } = Route.useParams() + const { tab } = Route.useSearch() + + return <ProjectOverview projectId={projectId} tab={tab} /> +} +``` + +Do not replace this with `getRouteApi('<guessed-id>')`, +`useParams({ from: '<guessed-id>' })`, or a cast. A pathless ancestor can make a +guessed id wrong at runtime even when weakened types let it compile. + +`useSearch({ strict: false })` is acceptable only in route-agnostic shared code +such as a generic URL-filter hook. It trades route-specific guarantees for +portability. + +## Recursive Composition + +A route declaration imports its concrete parent. A tree module imports children: + +```tsx +// routes/app/pages/Projects/pages/Item/route.tree.ts +import { Route as overviewRoute } from './pages/Overview/route' +import { Route as workspaceRoute } from './pages/Workspace/route' +import { projectItemIndexRoute, projectItemRoute } from './route' + +export const projectItemTree = projectItemRoute.addChildren([ + projectItemIndexRoute, + overviewRoute, + workspaceRoute, +]) +``` + +```tsx +// routes/app/pages/Projects/route.tree.ts +import { projectItemTree } from './pages/Item/route.tree' +import { Route as projectListRoute } from './pages/List/route' +import { projectsRoute } from './route' + +export const projectsTree = projectsRoute.addChildren([ + projectListRoute, + projectItemTree, +]) +``` + +For an organizational group with no route of its own, export the leaves as a +collection and spread them into the real parent: + +```tsx +// routes/auth/pages/PasswordRecovery/route.tree.ts +export const passwordRecoveryRoutes = [ + requestResetRoute, + resetPasswordRoute, +] + +// routes/auth/route.tree.ts +export const authTree = authRoute.addChildren([ + signInRoute, + ...passwordRecoveryRoutes, +]) +``` + +Do not annotate these values as `AnyRoute[]`. Generic widening erases the route +tree information that powers typed ids, params, search, links, and navigation. +Let inference preserve the concrete tuple/array types. + +Avoid generic factories such as +`createProjectRoutes<TParentRoute extends AnyRoute>(parent)`. They obscure the +real parent and make route identity easier to widen accidentally. Import the +concrete parent route instead. + +## Shared Search Contracts + +A search schema belongs to the route that reads it. If two sibling routes share +the same external query contract, place it in a sibling module: + +```text +routes/auth/ +├── search-schema.ts +└── pages/ + ├── AcceptInvite/route.tsx + └── SignUp/route.tsx +``` + +Both leaves import the schema. The parent `route.tsx` does not import from its +children, so the route graph stays acyclic. + +Validate search values from external URLs and deep links as untrusted input. +Choose strict, coercing, defaulting, or catch behavior according to the actual +contract; do not silently normalize values merely because another project did. + +## Routed Storybook And Tests + +`Route.useParams()` and `Route.useSearch()` resolve against the active match's +effective id. When the production page is below `id: 'app'`, a flat test route +with only the visible path does not provide the same match. + +Create a minimal parallel chain: + +```text +test root +└── pathless layout (id: app) + └── leaf (same visible full path) +``` + +The route objects need not be identical; the effective route-id chain must +match. Render shared application chrome around the routed outlet as test/story +shell content. This preserves production page code and its typed hooks. diff --git a/.agents/skills/state-management/SKILL.md b/.agents/skills/state-management/SKILL.md new file mode 100644 index 0000000..cf5bb12 --- /dev/null +++ b/.agents/skills/state-management/SKILL.md @@ -0,0 +1,263 @@ +--- +name: state-management +description: Use whenever work creates, changes, reviews, debugs, or consumes shared client state, especially Zustand stores, selectors, actions, store namespaces, feature or page stores, store factories and providers, persisted state, migrations, hydration guards, secure storage, authentication state, cross-store workflows, resets, or long-running client processes. Trigger when deciding whether state belongs in Zustand, TanStack Query, router params or search, React Hook Form, React state, or context, even if the request does not name a state-management library. +--- + +# State Management + +Use Zustand for **shared client-owned state**, not as the default owner of every +value. Choose the owner and lifetime before choosing store syntax. + +Adapt imports and storage implementations to the repository. Preserve these +ownership and security boundaries even when the local Zustand idiom differs. + +## Decide The Owner First + +Keep each kind of state with its real authority: + +- Refetchable external or backend data belongs to the query layer. Do not copy + query results into Zustand through `useEffect`. +- Resource identity belongs in path params. Filters, sorting, pagination, tabs, + and other navigable state belong in search params. +- Form fields, validation, dirty state, and submission state belong to the form. +- State used by one component belongs in React state. +- State shared only by one composable subtree usually belongs in context. +- Shared client state, imperative client state, state that crosses unrelated + React branches, and client processes that outlive one component may belong in + Zustand. + +Persistence is a separate decision. A value does not belong in Zustand merely +because it must survive a restart, and a Zustand value need not be persisted. + +Read `references/ownership-and-scope.md` when choosing between these owners or +between a singleton and a scoped store. + +## Build Bounded Capability Stores + +Create a store around one cohesive client capability. Do not merge unrelated +state merely to reduce the number of stores, and do not split one atomic +capability into a store per field. + +A module-level `create(...)` is a singleton regardless of where its file lives. +Use it only when the capability truly has one application-wide or +feature-wide lifetime. Use a `createStore` factory with context when state: + +- belongs to one mounted page, editor, or component instance; +- is initialized from props; +- must reset on unmount; +- may have multiple simultaneous instances; +- must be isolated per SSR request, test, or story. + +Locate the store at the narrowest boundary containing all of its consumers. +Follow **file-structure** for the concrete directory shape. + +## Choose The Store Definition Deliberately + +Prefer `create(combine(initialState, actions))` for a small store whose inferred +shape stays obvious. Use an explicit store type when it clarifies a factory, +public contract, complex async lifecycle, middleware stack, or process manager. +Never force `combine` merely to avoid writing a useful type. + +Keep ordinary state transitions beside their state. An action defined by one +store may read and mutate that store only; it must not import another store. +Keep a reusable initial state and provide an explicit reset when the capability +has a reset lifecycle. + +Do not store derived values that can be computed cheaply from current state. +Compute them in a selector or render instead of synchronizing them through an +effect. + +See `references/store-patterns.md` for simple, explicit, scoped, selector, and +reset examples. + +## Expose A Discoverable Namespace + +Expose the store through one capability namespace: + +```ts +export const preferencesStore = { + useStore: usePreferencesStore, +} +``` + +Selecting an ordinary field directly is the default: + +```ts +const theme = preferencesStore.useStore((state) => state.theme) +``` + +Do not generate one wrapper hook per field. Add a named hook only when it +expresses a meaningful derived concept, composes several stores, centralizes +equality behavior, or deliberately hides an unstable internal representation. + +A namespace may also expose feature-level commands and hooks. Treat the +namespace as the public facade of the capability, not as a claim that every +member is a literal Zustand action. + +## Subscribe Narrowly + +- Select the narrowest field or derived primitive the consumer needs. +- Use the repository's shallow-equality helper, such as `useShallow`, when a + selector returns an object, array, or other shallow-comparable collection. +- Avoid `useStore()` without a selector; it subscribes to every change. +- Never mutate objects, arrays, `Map`, or `Set` in place. Return a new reference. +- Inside React, subscribe through the hook. Outside React, use `getState()` or a + public command. +- Use direct `setState()` primarily for tests, stories, framework integration, + and controlled setup. Production mutations go through owned actions or + commands. + +## Keep Orchestration Outside Store Actions + +Feature-level commands and hooks may coordinate several stores, the query +client, storage capabilities, and transports when the workflow clearly belongs +to that feature. Logout is a typical example. + +Prefer a plain command for orchestration that does not require React, then wrap +it in a hook or mutation when the UI needs pending, error, or success state. +Keep navigation, toasts, and other caller-specific UX at the caller unless they +are an invariant of the workflow. + +Derived hooks may subscribe to multiple stores. This is composition, not a +store-to-store dependency. + +Read `references/processes-and-coordination.md` for command, logout, and +cross-store examples. + +## Persist Only An Explicit Contract + +Do not add `persist` by habit. For every persisted store: + +- give the storage key one stable owner; +- whitelist the minimal persisted shape with `partialize`; +- exclude actions, hydration state, in-flight state, caches, runtime handles, + access tokens, and other transient values; +- establish a version before the persisted contract ships; +- validate persisted `unknown` input rather than trusting a cast; +- define migrations for breaking shape changes; +- define a custom merge when nested defaults require more than Zustand's + shallow merge; +- use the repository's storage adapter rather than reading or parsing + Zustand's serialized value elsewhere. + +Choose failure behavior by data class. A disposable cache may reset explicitly. +A decrypt, corruption, or migration failure for credentials, user-created +offline data, or another critical store must surface as an error and must not +silently become “no saved state.” + +Do not rely on `await set(...)` as a portable persistence-completion contract. +When the next operation requires durable completion, put that guarantee behind +a dedicated, tested persistence capability. + +## Model Hydration As A Prerequisite + +Synchronous and asynchronous adapters hydrate differently. Do not add a +hydration gate when no consumer depends on it, and do not assume all persisted +stores hydrate asynchronously. + +For critical asynchronous state, model at least: + +```ts +type HydrationState = + | { status: "hydrating"; error: null } + | { status: "ready"; error: null } + | { status: "error"; error: unknown } +``` + +Use ordered guards when application regions depend on persisted state: + +```tsx +<SecureStoresGuard> + <SessionGuard> + <AccessGuard> + <CriticalDataGuard>{children}</CriticalDataGuard> + </AccessGuard> + </SessionGuard> +</SecureStoresGuard> +``` + +Each guard owns one prerequisite and either renders its loading, error, +blocked, or retry state or passes `children`. Outer guards establish the +invariants assumed by inner guards. Never interpret pre-hydration defaults as +logout, missing credentials, or permission denial. + +Read `references/persistence-and-security.md` before changing persistence, +hydration, authentication storage, encryption, or guards. + +## Keep Secrets Out Of Zustand When Possible + +Secure storage protects data at rest; it does not protect plaintext after a +secret is returned to renderer JavaScript. + +- Keep reactive session metadata in Zustand: status, account identity, + expiration, lock state, and hydration state. +- Keep a short-lived access token in private auth-client memory only when the + renderer must perform authenticated requests. It rarely needs reactivity. +- Keep refresh tokens in an OS-backed native vault or an HttpOnly backend + session where the platform permits. +- Keep private keys non-exportable behind a native or platform capability. + Expose operations such as `sign`, `decrypt`, or `refreshSession`, not + `getSecret` or `getPrivateKey`. +- Never call an adapter `secure` when it is only `localStorage`, ordinary + filesystem storage, or another unprotected backend. +- A storage read returns `null` only when data is genuinely absent. Decryption + or key-access failure must remain an error. + +If architecture forces a raw secret into JavaScript, keep it out of global +reactive state, persistence, devtools, logs, and long-lived strings. Limit it to +the narrowest operation and treat buffer clearing as best effort, not a +security guarantee. + +Follow **native-integration** for capability and RPC boundaries and +**logging** for keeping secrets out of diagnostic records and +**error-handling** for safe incident context. + +## Allow Explicit Process Stores + +A Zustand store may manage a long-running client process when the process: + +- outlives one component or route; +- needs imperative start, cancel, retry, resume, or reattach operations; +- publishes progress to unrelated consumers; +- has a client-owned lifecycle rather than query-owned server state. + +Model explicit status transitions and reject duplicate or stale work. Keep +`AbortController`, streams, sockets, and other runtime handles outside +observable state when consumers do not need them. Never persist those handles; +persist only a deliberate resume record through its owning storage capability. + +When transitions become complex, use an explicit reducer or state machine +inside the capability rather than hiding an implicit state machine in scattered +booleans. + +Read `references/processes-and-coordination.md` before creating a process store. + +## Verify The Boundary + +Before finishing state-management work: + +1. Confirm each value still has one owner. +2. Confirm URL, form, and query state were not duplicated into Zustand. +3. Confirm singleton versus scoped lifetime is intentional. +4. Inspect every React subscription for unnecessary breadth. +5. Exercise reset, logout, remount, and concurrent-operation behavior. +6. For persistence, test migration, corrupted input, missing data, hydration + failure, retry, and durable-write ordering where relevant. +7. For secrets, verify what crosses into renderer memory and whether a narrower + capability can keep it out. + +## Related Skills + +- **api-integration** owns external operations, server state, query identities, + mutations, invalidation, and auth transport behavior. +- **routing** owns path and search state with navigation meaning. +- **forms** owns form values, validation, submission, and field errors. +- **components** owns React-local state, context, composition, and effect + avoidance. +- **file-structure** owns the concrete placement of store files and private + implementation. +- **native-integration** owns OS-backed storage, cryptography, privileged + capabilities, and renderer bridges. +- **logging** owns diagnostic records and the rule that secrets never enter + them. +- **error-handling** owns incident-reporting policy and safe reporting context. diff --git a/.agents/skills/state-management/references/ownership-and-scope.md b/.agents/skills/state-management/references/ownership-and-scope.md new file mode 100644 index 0000000..b56b011 --- /dev/null +++ b/.agents/skills/state-management/references/ownership-and-scope.md @@ -0,0 +1,165 @@ +# State Ownership And Scope + +Use this reference when deciding whether state belongs in Zustand and whether a +store should be a module singleton or a scoped instance. + +## Contents + +- [Ownership Matrix](#ownership-matrix) +- [Do Not Duplicate Query State](#do-not-duplicate-query-state) +- [Singleton Stores](#singleton-stores) +- [Scoped Store Factories](#scoped-store-factories) +- [Context Without Zustand](#context-without-zustand) + +## Ownership Matrix + +| State | Default owner | +| --- | --- | +| Refetchable backend or external data | Query layer | +| Resource identity | Route path params | +| Filters, sorting, pagination, tabs, view options | Route search params | +| Form values, validation, dirty and submit state | Form library | +| One component | React state or reducer | +| One composable subtree | React context | +| Shared client-owned state | Zustand | +| Long-running client process | Process store or dedicated client capability | +| Restart-surviving subset | Explicit persistence boundary | + +These are ownership defaults, not library prohibitions. Change one only when the +value has a different real authority, and document that authority. + +## Do Not Duplicate Query State + +Do not copy query data into a store: + +```ts +const query = useQuery(projectQueryOptions(projectId)) +const setProject = projectStore.useStore((state) => state.setProject) + +useEffect(() => { + if (query.data) setProject(query.data) +}, [query.data, setProject]) +``` + +Read the query directly. Keep only genuine client state in Zustand: + +```ts +const query = useQuery(projectQueryOptions(projectId)) +const selectedPanel = projectWorkspaceStore.useStore( + (state) => state.selectedPanel, +) +``` + +An offline editor, local draft authority, or client process may intentionally +own a snapshot. Make that ownership explicit; do not create it merely to avoid +reading the query cache. + +## Singleton Stores + +A module-level store is appropriate for a true singleton: + +```ts +const usePreferencesStore = create( + combine( + { theme: "system" as ThemePreference }, + (set) => ({ + setTheme: (theme: ThemePreference) => set({ theme }), + }), + ), +) + +export const preferencesStore = { + useStore: usePreferencesStore, +} +``` + +Typical singleton capabilities include application preferences, one active +session, and one application-wide background queue. + +Colocating this file with a page narrows its ownership but does not change its +runtime lifetime. It remains a module singleton. + +## Scoped Store Factories + +Use a factory when each mounted owner needs an independent instance: + +```tsx +import { + createContext, + type PropsWithChildren, + useContext, + useState, +} from "react" +import { createStore, useStore } from "zustand" + +type ProjectWorkspaceStore = { + projectId: string + selectedNodeId: string | null + selectNode: (nodeId: string | null) => void +} + +const createProjectWorkspaceStore = (projectId: string) => + createStore<ProjectWorkspaceStore>()((set) => ({ + projectId, + selectedNodeId: null, + selectNode: (selectedNodeId) => set({ selectedNodeId }), + })) + +type ProjectWorkspaceStoreApi = ReturnType< + typeof createProjectWorkspaceStore +> + +const ProjectWorkspaceStoreContext = + createContext<ProjectWorkspaceStoreApi | null>(null) + +export function ProjectWorkspaceStoreProvider({ + projectId, + children, +}: PropsWithChildren<{ projectId: string }>) { + const [store] = useState(() => createProjectWorkspaceStore(projectId)) + + return ( + <ProjectWorkspaceStoreContext.Provider value={store}> + {children} + </ProjectWorkspaceStoreContext.Provider> + ) +} + +export function useProjectWorkspaceStore<T>( + selector: (state: ProjectWorkspaceStore) => T, +) { + const store = useContext(ProjectWorkspaceStoreContext) + + if (!store) { + throw new Error( + "useProjectWorkspaceStore must be used within ProjectWorkspaceStoreProvider", + ) + } + + return useStore(store, selector) +} +``` + +Create the store once for that provider instance. Do not recreate it on every +render. Key or remount the owner deliberately when its identity changes. + +Use the same pattern for SSR request isolation. Never share a mutable +module-level store between server requests. + +## Context Without Zustand + +Do not add Zustand merely because several compound components share state: + +```tsx +const SelectionContext = createContext<SelectionContextValue | null>(null) +``` + +Prefer plain context when: + +- all consumers live under one obvious provider; +- no imperative access outside React is needed; +- update frequency and provider scope are controlled; +- the state should disappear with that subtree. + +Use a scoped Zustand store when selector-based subscriptions, imperative access, +or a substantial state transition model materially improves that subtree. diff --git a/.agents/skills/state-management/references/persistence-and-security.md b/.agents/skills/state-management/references/persistence-and-security.md new file mode 100644 index 0000000..a31ca44 --- /dev/null +++ b/.agents/skills/state-management/references/persistence-and-security.md @@ -0,0 +1,391 @@ +# Persistence, Hydration, Guards, And Secrets + +Use this reference before implementing or reviewing persisted state, +authentication storage, secure adapters, migrations, hydration, or application +guards. + +## Contents + +- [Persist A Stable Subset](#persist-a-stable-subset) +- [Validate, Migrate, And Merge](#validate-migrate-and-merge) +- [Hydration State](#hydration-state) +- [Guard Composition](#guard-composition) +- [Storage Failure Semantics](#storage-failure-semantics) +- [Secret Placement](#secret-placement) +- [Platform Options](#platform-options) + +## Persist A Stable Subset + +Persist only state that must survive a restart: + +```ts +import { create } from "zustand" +import { + combine, + createJSONStorage, + persist, +} from "zustand/middleware" + +const usePreferencesStore = create( + persist( + combine( + { + theme: "system" as ThemePreference, + density: "comfortable" as DensityPreference, + previewTheme: null as ThemePreference | null, + }, + (set) => ({ + setTheme: (theme: ThemePreference) => set({ theme }), + }), + ), + { + name: "preferences", + version: 1, + storage: createJSONStorage(() => applicationStorage), + partialize: (state) => ({ + theme: state.theme, + density: state.density, + }), + }, + ), +) +``` + +`previewTheme`, actions, hydration state, request state, and runtime handles do +not persist. + +Do not read or parse the serialized Zustand value from another module. If +pre-React bootstrap needs a persisted preference, expose that through the +storage capability that owns the contract. + +## Validate, Migrate, And Merge + +Persisted data is untrusted `unknown` input. Validate it with the repository's +existing schema mechanism: + +```ts +const persistedPreferencesSchema = z.object({ + theme: z.enum(["system", "light", "dark"]), + density: z.enum(["compact", "comfortable"]), +}) + +type PersistedPreferences = z.infer< + typeof persistedPreferencesSchema +> +``` + +Use an explicit persisted return type: + +```ts +partialize: (state): PersistedPreferences => ({ + theme: state.theme, + density: state.density, +}) +``` + +Migration receives `unknown`: + +```ts +migrate: (persistedState, version) => { + if (version === 0) { + const legacy = legacyPreferencesSchema.parse(persistedState) + + return { + theme: legacy.colorMode, + density: "comfortable", + } + } + + return persistedPreferencesSchema.parse(persistedState) +} +``` + +Zustand's default merge is shallow. Merge nested defaults deliberately: + +```ts +merge: (persistedState, currentState) => { + const persisted = persistedWorkspaceSchema.parse(persistedState) + + return { + ...currentState, + workspace: { + ...currentState.workspace, + ...persisted.workspace, + }, + } +} +``` + +Do not add a migration function that silently converts every invalid critical +value into an empty default. Decide whether the data is disposable or whether +failure must block and offer recovery. + +## Hydration State + +Synchronous storage may hydrate during store creation. Asynchronous storage +hydrates later. Add a gate only when consumers must not observe defaults before +hydration. + +For simple noncritical state, a boolean may be sufficient: + +```ts +type HydrationState = { + hasHydrated: boolean +} +``` + +For critical state, preserve loading and error: + +```ts +type HydrationStatus = + | { status: "hydrating"; error: null } + | { status: "ready"; error: null } + | { status: "error"; error: unknown } +``` + +Several secure stores may share a hydration registry: + +```ts +type SecureStoresHydration = { + session: HydrationStatus + accounts: HydrationStatus +} +``` + +Set `skipHydration: true` when native preparation, an unlock step, SSR, or +another prerequisite must happen before the first read: + +```ts +persist(stateCreator, { + name: "session", + storage: createJSONStorage(() => secureSessionStorage), + skipHydration: true, +}) +``` + +Start hydration explicitly when the adapter requires preparation: + +```ts +export async function hydrateSessionStore() { + hydrationStore.useStore.getState().begin("session") + + try { + await sessionVault.prepare() + await sessionStore.useStore.persist.rehydrate() + hydrationStore.useStore.getState().succeed("session") + } catch (error) { + hydrationStore.useStore.getState().fail("session", error) + throw error + } +} +``` + +Keep the original error. Do not invent a normalized error representation solely +for hydration. + +Do not assume `await set(...)` is a portable durable-write contract. If a +workflow must wait until encrypted persistence completes before locking, +navigating, or making another request, expose and test that guarantee through +the persistence capability. + +## Guard Composition + +Compose one prerequisite per guard: + +```tsx +function Application({ children }: PropsWithChildren) { + return ( + <SecureStoresGuard> + <SessionGuard> + <AccessGuard> + <CriticalDataGuard> + <ApplicationLayout>{children}</ApplicationLayout> + </CriticalDataGuard> + </AccessGuard> + </SessionGuard> + </SecureStoresGuard> + ) +} +``` + +The order is the contract: + +1. Secure persistence is readable. +2. Session presence is known. +3. Authorization is settled. +4. Critical application data is available. +5. The application layout and its consumers may mount. + +Each guard selects only its prerequisite and owns its fallback: + +```tsx +function SecureStoresGuard({ children }: PropsWithChildren) { + const hydration = hydrationStore.useStore((state) => state.session) + + if (hydration.status === "hydrating") { + return <FullScreenSpinner /> + } + + if (hydration.status === "error") { + return ( + <SecureStorageError + error={hydration.error} + onRetry={hydrateSessionStore} + /> + ) + } + + return children +} +``` + +Do not let a guard infer logout, denial, or missing data from a value whose +prerequisite has not passed. A hydration error must not automatically clear the +session. + +Hydration may start in the application entrypoint so no synchronization effect +is needed: + +```ts +void hydrateSecureStores().catch(() => { + // The hydration registry retains the original error for its guard. +}) + +createRoot(rootElement).render(<Application />) +``` + +An established query layer is also acceptable for async bootstrap when its +pending, retry, and error lifecycle fits the operation. + +## Storage Failure Semantics + +Return `null` only for genuine absence: + +```ts +async function getItem(name: string) { + const encryptedValue = backingStorage.getItem(name) + + if (encryptedValue === null) return null + + return nativeSecurity.decrypt(encryptedValue) +} +``` + +Do not swallow decryption failure: + +```ts +// Wrong: this changes "unreadable" into "missing". +async function getItem(name: string) { + try { + return await decrypt(backingStorage.getItem(name)) + } catch { + return null + } +} +``` + +The false “missing” result can hydrate empty defaults and later overwrite +recoverable encrypted data. + +## Secret Placement + +Secure storage protects data at rest. Once plaintext is returned to renderer +JavaScript, it may be reachable through application code, devtools, heap +snapshots, injected code, logs, or accidental persistence. + +Prefer this separation: + +```text +Zustand + session status + account identity + expiration + lock and hydration state + +Private auth-client memory + short-lived access token, only if renderer requests require it + +Native or backend vault + refresh token + private keys + long-lived credentials +``` + +An access token rarely needs reactivity. Prefer a private provider when the +renderer must hold it: + +```ts +let accessToken: string | null = null + +export const accessTokenProvider = { + get: () => accessToken, + set: (value: string | null) => { + accessToken = value + }, + clear: () => { + accessToken = null + }, +} +``` + +This reduces accidental exposure but does not protect against full renderer +compromise. + +Do not export raw private keys. Keep them non-exportable and expose operations: + +```ts +type SigningCapability = { + createKey: () => Promise<{ + keyId: string + publicKey: string + }> + sign: (input: { + keyId: string + payload: Uint8Array + }) => Promise<Uint8Array> + deleteKey: (keyId: string) => Promise<void> +} +``` + +Zustand may keep `keyId`, `publicKey`, and readiness state. The private key stays +behind the capability. + +When a raw key must temporarily enter JavaScript, keep it in the narrowest +lexical scope, prefer mutable byte buffers over immutable strings, never place +it in Zustand or persistence, and clear buffers as best effort. JavaScript +runtimes may retain copies, so do not claim guaranteed zeroization. + +## Platform Options + +Inspect the repository's threat model, existing native capabilities, and the +current official platform documentation before choosing an adapter. Security +semantics and fallbacks vary by operating system and runtime version. + +Choose the strongest boundary the platform supports: + +- Browser with backend: prefer a Backend-for-Frontend or HttpOnly, Secure, + SameSite session so tokens never enter application JavaScript. +- Browser without backend mediation: keep short-lived access tokens in memory; + never use `localStorage` or `sessionStorage` for credentials; require refresh + token rotation or sender constraint when refresh tokens are issued. +- Web cryptography: prefer non-extractable `CryptoKey` handles for local key + operations. They prevent raw export but cannot prevent compromised same-origin + code from invoking an allowed operation. +- Electron or another desktop shell: keep secrets and cryptographic operations + in the privileged native/main capability. Use OS Keychain, DPAPI, Secret + Service, or a framework wrapper such as Electron `safeStorage`; verify that + the selected Linux backend is not a plaintext fallback. +- Apple platforms: prefer Keychain and non-exportable key operations, using + Secure Enclave where the required algorithm and lifecycle permit it. +- Android: prefer Android Keystore and hardware-backed, non-exportable keys when + available. +- React Native or Expo: use the established Keychain/Keystore-backed secure + storage adapter for small tokens and secrets; prefer native non-exportable key + APIs over storing raw PEM keys. + +For larger encrypted data, store ciphertext in ordinary application storage and +keep only its data-encryption or wrapping key in the OS vault. + +Expose narrow native methods such as `sign`, `refreshSession`, and +`clearSession`. Never expose a generic `getSecret` or an unrestricted IPC +surface. diff --git a/.agents/skills/state-management/references/processes-and-coordination.md b/.agents/skills/state-management/references/processes-and-coordination.md new file mode 100644 index 0000000..eec5867 --- /dev/null +++ b/.agents/skills/state-management/references/processes-and-coordination.md @@ -0,0 +1,233 @@ +# Process Stores And Coordination + +Use this reference for long-running client processes, cross-store workflows, +logout, and feature-level commands. + +## Contents + +- [Process Store](#process-store) +- [Runtime Handles](#runtime-handles) +- [Stale Work And Reattachment](#stale-work-and-reattachment) +- [Cross-Store Commands](#cross-store-commands) +- [Hook Adapters](#hook-adapters) + +## Process Store + +Use a process store when work must continue independently of one component: + +```ts +type ExportProcessState = { + status: "idle" | "running" | "completed" | "failed" + progress: number + error: unknown +} + +type ExportProcessActions = { + start: (input: ExportInput) => Promise<void> + cancel: () => void + reset: () => void +} + +type ExportProcessStore = ExportProcessState & ExportProcessActions + +const initialState: ExportProcessState = { + status: "idle", + progress: 0, + error: null, +} + +let activeController: AbortController | null = null + +const useExportProcessStore = create<ExportProcessStore>()((set, get) => ({ + ...initialState, + + start: async (input) => { + if (get().status === "running") return + + const controller = new AbortController() + activeController = controller + set({ status: "running", progress: 0, error: null }) + + try { + await exportClient.run(input, { + signal: controller.signal, + onProgress: (progress) => set({ progress }), + }) + + if (activeController !== controller || controller.signal.aborted) return + + set({ status: "completed", progress: 1 }) + } catch (error) { + if (activeController !== controller || controller.signal.aborted) return + + set({ status: "failed", error }) + } finally { + if (activeController === controller) activeController = null + } + }, + + cancel: () => { + activeController?.abort() + activeController = null + set(initialState) + }, + + reset: () => { + if (get().status === "running") return + set(initialState) + }, +})) + +export const exportProcessStore = { + useStore: useExportProcessStore, +} +``` + +The identity check prevents a late completion from an older operation from +overwriting a newer run. + +## Runtime Handles + +Keep runtime handles outside observable state unless consumers genuinely need +them: + +- `AbortController`; +- sockets and streams; +- timers; +- imperative clients; +- native resource handles; +- promises and async iterators. + +They are implementation details, are usually non-serializable, and must never +enter persisted state. + +If the process must survive a renderer restart, persist a deliberate resume +record through the process's storage capability: + +```ts +type ResumeRecord = { + runId: string + resourceId: string + startedAt: string +} +``` + +Do not persist the controller, stream, client, or accumulated implementation +object graph. + +## Stale Work And Reattachment + +Long-running stores must define: + +- what happens when `start` is called twice; +- how cancellation settles state; +- whether a finished result may update a newer run; +- whether restart means reattach, resume, reconcile, or fail; +- who owns any persisted resume record; +- when retry is allowed. + +Prefer explicit status or transition models over independent booleans: + +```ts +type ProcessStatus = + | { type: "idle" } + | { type: "running"; runId: string } + | { type: "resumable"; runId: string } + | { type: "completed" } + | { type: "failed"; error: unknown } +``` + +Use a reducer or state machine when valid transitions are no longer obvious +from a small store. + +## Cross-Store Commands + +A store action owns only its own state: + +```ts +const useSessionStore = create( + combine(sessionInitialState, (set) => ({ + clear: () => set(sessionInitialState), + })), +) +``` + +Do not import peer stores into that state creator. Coordinate them in a +feature-level command: + +```ts +export async function logout() { + await queryClient.cancelQueries() + queryClient.clear() + + sessionStore.useStore.getState().clear() + userStore.useStore.getState().clear() + notificationsStore.useStore.getState().clear() + + await sessionVault.clearSession() +} +``` + +The explicit list is intentional. Avoid a generic reset registry until several +real workflows prove that it improves ownership rather than hiding it. + +The command may live in the session capability and appear in its namespace: + +```ts +export const sessionStore = { + useStore: useSessionStore, + logout, +} +``` + +This is allowed because the namespace is a feature facade. `logout` is not +pretending to be an action inside the Zustand state creator. + +If several stores must always change atomically, reconsider their boundary. +They may be slices of one capability rather than independent stores. + +## Hook Adapters + +Wrap a plain command when React needs mutation lifecycle: + +```ts +function useLogoutMutation() { + return useMutation({ + mutationKey: ["session", "logout"], + mutationFn: logout, + }) +} + +export const sessionStore = { + useStore: useSessionStore, + logout, + useLogoutMutation, +} +``` + +Keep caller-specific navigation and feedback at the caller: + +```ts +const logoutMutation = sessionStore.useLogoutMutation() + +const handleLogout = async () => { + await logoutMutation.mutateAsync() + await router.invalidate() +} +``` + +A feature-level hook may compose multiple stores reactively: + +```ts +function useCanStartExport() { + const projectId = projectStore.useStore((state) => state.selectedId) + const processStatus = exportProcessStore.useStore( + (state) => state.status, + ) + + return projectId !== null && processStatus === "idle" +} +``` + +This is allowed. The state creators remain independent; the hook owns the +composition. diff --git a/.agents/skills/state-management/references/store-patterns.md b/.agents/skills/state-management/references/store-patterns.md new file mode 100644 index 0000000..15cc8f1 --- /dev/null +++ b/.agents/skills/state-management/references/store-patterns.md @@ -0,0 +1,211 @@ +# Store Patterns + +Use this reference for ordinary store definitions, namespaces, selectors, and +reset behavior. + +## Contents + +- [Simple Inferred Store](#simple-inferred-store) +- [Explicit Store Contract](#explicit-store-contract) +- [Namespace And Selectors](#namespace-and-selectors) +- [Immutable Updates](#immutable-updates) +- [Reset](#reset) + +## Simple Inferred Store + +Use `combine` when inference keeps a small store clearer: + +```ts +import { create } from "zustand" +import { combine } from "zustand/middleware" + +const initialState = { + selectedId: null as string | null, + expandedIds: [] as string[], +} + +const useNavigationStore = create( + combine(initialState, (set) => ({ + select: (selectedId: string | null) => set({ selectedId }), + expand: (id: string) => + set((state) => ({ + expandedIds: state.expandedIds.includes(id) + ? state.expandedIds + : [...state.expandedIds, id], + })), + reset: () => set({ ...initialState, expandedIds: [] }), + })), +) + +export const navigationStore = { + useStore: useNavigationStore, +} +``` + +Do not use state replacement with an inferred `combine` store. Replacing only +the initial-state shape can remove its actions. + +## Explicit Store Contract + +Use an explicit contract when it documents a factory, async lifecycle, public +package surface, or complex store better than inference: + +```ts +type UploadState = { + status: "idle" | "uploading" | "completed" | "failed" + progress: number + error: unknown +} + +type UploadActions = { + setProgress: (progress: number) => void + fail: (error: unknown) => void + complete: () => void + reset: () => void +} + +type UploadStore = UploadState & UploadActions + +const initialUploadState: UploadState = { + status: "idle", + progress: 0, + error: null, +} + +const useUploadStore = create<UploadStore>()((set) => ({ + ...initialUploadState, + setProgress: (progress) => set({ status: "uploading", progress }), + fail: (error) => set({ status: "failed", error }), + complete: () => set({ status: "completed", progress: 1 }), + reset: () => set(initialUploadState), +})) +``` + +Keep useful types beside the capability. Do not move them into a generic +`types.ts` file. + +## Namespace And Selectors + +Expose one discoverable namespace: + +```ts +const useStore = create(/* ... */) + +export const workspaceStore = { + useStore, +} +``` + +Select ordinary fields directly: + +```tsx +const selectedId = workspaceStore.useStore((state) => state.selectedId) +const select = workspaceStore.useStore((state) => state.select) +``` + +Select several fields with shallow equality: + +```tsx +const { status, progress } = uploadStore.useStore( + useShallow((state) => ({ + status: state.status, + progress: state.progress, + })), +) +``` + +Do not subscribe to the whole store: + +```tsx +// Avoid: rerenders for every store change. +const store = uploadStore.useStore() +``` + +Create a named hook when it owns meaningful logic: + +```ts +const useCanCancelUpload = () => + uploadStore.useStore( + (state) => state.status === "uploading" && state.progress < 1, + ) +``` + +A named hook may compose stores without creating a dependency between their +state creators: + +```ts +const useCanOpenWorkspace = () => { + const isAuthenticated = sessionStore.useStore( + (state) => state.status === "authenticated", + ) + const projectId = projectStore.useStore((state) => state.selectedId) + + return isAuthenticated && projectId !== null +} +``` + +Outside React, prefer a public command or use `getState()`: + +```ts +workspaceStore.useStore.getState().select(projectId) +``` + +## Immutable Updates + +Return new references: + +```ts +set((state) => ({ + items: [...state.items, item], +})) +``` + +Create new `Map` and `Set` instances: + +```ts +set((state) => ({ + selectedIds: new Set(state.selectedIds).add(id), +})) +``` + +Never mutate and return the existing reference: + +```ts +// Wrong: subscribers may not observe a change. +set((state) => { + state.selectedIds.add(id) + return { selectedIds: state.selectedIds } +}) +``` + +## Reset + +Model reset as an owned action: + +```ts +const createInitialState = (): NavigationState => ({ + selectedId: null, + expandedIds: new Set(), +}) + +const useNavigationStore = create<NavigationState & NavigationActions>()( + (set) => ({ + ...createInitialState(), + reset: () => set(createInitialState()), + }), +) +``` + +Use a factory when initial state contains mutable collections so reset never +reuses a previously mutated reference. + +Direct `setState()` is acceptable for tests and stories: + +```ts +beforeEach(() => { + useNavigationStore.setState(createInitialState()) +}) +``` + +Do not replace state with a state-only object because that removes actions. +Reset through the owned action when it represents the public test contract. diff --git a/.agents/skills/user-feedback/SKILL.md b/.agents/skills/user-feedback/SKILL.md new file mode 100644 index 0000000..e353abd --- /dev/null +++ b/.agents/skills/user-feedback/SKILL.md @@ -0,0 +1,217 @@ +--- +name: user-feedback +description: Use whenever work adds, changes, reviews, or debugs user-facing outcome feedback such as error, success, warning, or informational messages; toasts, snackbars, alerts, banners, dialogs, inline errors, empty or failed regions, form submission feedback, retry actions, crash or initialization fallbacks, domain-outcome presentation, or mapping technical failures to localized product copy. Trigger even when the request only mentions showError, showSuccess, notifications, server messages, error.message, mutation onError or onSuccess UX, or choosing how and where to tell the user what happened. +--- + +# User Feedback + +Present one clear outcome at the boundary that owns the user interaction. Choose +the surface from the duration, scope, severity, and recovery needs of the +message; do not route every outcome through a toast. + +Feedback is presentation. It does not decide whether a failure is logged, +reported as an incident, retried, or converted. + +## Inspect The Existing Feedback System + +Before adding feedback: + +1. Find the repository's UI primitives and existing toast, snackbar, banner, + alert, dialog, and error-region components. +2. Find any feedback facade or event bus and the runtime composition root that + renders it. +3. Check how localization, accessibility announcements, durations, actions, and + duplicate messages are handled. +4. Trace whether the same interaction already presents inline state or + navigation that makes another message redundant. +5. Find the technical error owner in **error-handling** before adding failure + feedback. + +Reuse the established presentation path. Do not introduce another toast library +or global event bus for one feature. + +## Choose The Smallest Correct Surface + +Use the surface that matches the problem: + +| Surface | Prefer for | +| --- | --- | +| Field message | Validation or server failure tied to one form field | +| Inline region | A failed or unavailable section with local retry | +| Toast or snackbar | A transient action outcome that does not block work | +| Banner | Persistent page- or application-level degraded state | +| Dialog or native alert | A blocking decision or consequence requiring acknowledgement | +| Route or subtree fallback | A render failure that invalidates that region | +| Application fallback | Initialization or unrecoverable root failure | + +Do not use a transient toast for a persistent failure. Do not replace an entire +page when only one region failed. Do not show both an inline message and a toast +for the same outcome unless they serve demonstrably different purposes. + +Read `references/presentation-patterns.md` for adaptable mapping, inline, +mutation, and fallback examples. + +## Use Product Copy, Not Technical Messages + +Do not show `error.message` to the user by default. It may be technical, +unstable, unlocalized, unsafe, or meaningless outside developer diagnostics. + +Prefer: + +1. a localized message selected by a known typed outcome; +2. a user-safe message explicitly guaranteed by the external contract; +3. a localized operation-specific fallback. + +Do not string-match a technical message to choose feedback when a status, code, +or discriminant exists. Do not expose stack traces, request bodies, provider +responses, identifiers the user cannot act on, or secret material. + +## Map Meaningful Outcomes Explicitly + +Keep feedback mapping close to the feature or shared domain boundary that owns +the meaning: + +```ts +function getProjectUpdateFeedback(error: unknown): Feedback { + if (isApprovalRequired(error)) { + return { + kind: "warning", + message: translate("Approval is required before this change can apply."), + } + } + + return { + kind: "error", + message: translate("Unable to update the project."), + } +} +``` + +Promote a mapper to shared `common` code only when genuinely different +consumers need the same meaning. Follow **file-structure** for its placement. + +Keep mapping pure: it selects presentation data. It must not report, log, +navigate, mutate state, or emit the feedback itself. + +## Keep Feedback And Reporting Independent + +Avoid APIs such as: + +```ts +showError(error, { report: true }) +``` + +A presentation helper cannot know whether a query cache, global listener, or +caller already reported the failure. Hidden reporting creates duplicates and +makes UI code control observability policy. + +When one interaction boundary owns both decisions, keep them visible: + +```ts +catch (error) { + reportError(error, { + module: "projects", + context: { projectId }, + }) + + showError({ + message: translate("Unable to update the project."), + }) +} +``` + +If a central observer already reports the failure, call only the feedback path +locally. + +## Treat Success As Optional Feedback + +Do not emit a success toast after every completed operation. + +Skip it when success is already obvious because the UI navigated, closed, +updated the resource, or displayed the new state. Use explicit success +feedback when completion would otherwise be ambiguous, delayed, performed in +the background, or especially consequential. + +Success copy should state the completed result, not merely `"Success"`. + +## Keep Validation Near The Field + +Client validation and server field errors belong beside their fields. Do not +replace them with a global toast. + +Use a form-level or toast failure only for a submission problem that cannot be +assigned to one field. Present one path: after mapping a server error to a +field, do not also show the same failure as a general error. + +Follow **forms** for schemas, React Hook Form, `setError`, submission state, and +field composition. + +## Make Recovery Actionable + +When the user can recover, present the relevant action: + +- retry the failed region or operation; +- reload or restart after an unrecoverable boundary; +- return to a stable route; +- reauthenticate when the session is no longer valid; +- open the resource created by an asynchronous or approval flow; +- dismiss a transient message. + +Do not offer retry when the operation is unsafe to repeat or when +**error-handling** has not established a retry policy. + +Preserve the user's entered data and surrounding usable state whenever +possible. + +## Handle Query And Mutation Feedback Locally + +Central Query or Mutation cache callbacks may own technical reporting, but they +do not know whether a toast, inline state, or no message is appropriate. + +- Present initial query failure in the failed region. +- Preserve cached data during a background refetch failure when possible. +- Present mutation feedback at the interaction boundary. +- Avoid global automatic error toasts for all queries and mutations. +- Keep cache invalidation and canonical lifecycle behavior in + **api-integration**. + +## Keep Feedback Accessible And Localized + +- Use the existing localization system for product copy. +- Ensure dynamic updates are announced through the established accessible + toast, live-region, alert, or native mechanism. +- Keep actionable controls keyboard and screen-reader reachable. +- Do not rely on color or an icon alone to communicate severity. +- Give persistent messages a visible dismissal or recovery path when + appropriate. +- Avoid durations too short for the message or action. + +## Verify The Result + +Before finishing: + +- one outcome produces one presentation; +- the surface matches the scope and persistence of the outcome; +- technical `error.message` is not exposed accidentally; +- copy is localized and actionable; +- known outcomes use typed fields rather than string matching; +- success feedback is not redundant with visible state; +- field errors remain attached to fields; +- feedback does not secretly log or report; +- retry is safe and owned by the technical error flow; +- accessibility and dismissal behavior match existing primitives. + +## Related Skills + +- Catch ownership, reporting, retry, cancellation, and boundaries → + **error-handling**. +- Form validation, submission, and server field errors → **forms**. +- UI primitives, composition, async-region rendering, and fallback components + → **components**. +- Query and mutation lifecycle, typed transport errors, and invalidation → + **api-integration**. +- Translation keys, interpolation, plurals, and locale-reactive copy → + **localization**. +- Placement and promotion of shared feedback modules → **file-structure**. +- Feedback notifications arriving from a native host → + **native-integration**. diff --git a/.agents/skills/user-feedback/agents/openai.yaml b/.agents/skills/user-feedback/agents/openai.yaml new file mode 100644 index 0000000..2e41ee4 --- /dev/null +++ b/.agents/skills/user-feedback/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "User Feedback" + short_description: "Safe and contextual outcome feedback" + default_prompt: "Use $user-feedback to design or review user-facing success, warning, error, and recovery feedback." diff --git a/.agents/skills/user-feedback/references/presentation-patterns.md b/.agents/skills/user-feedback/references/presentation-patterns.md new file mode 100644 index 0000000..1fab98d --- /dev/null +++ b/.agents/skills/user-feedback/references/presentation-patterns.md @@ -0,0 +1,173 @@ +# User Feedback Presentation Patterns + +Adapt these examples to the repository's existing primitives, localization, and +error contracts. The names are illustrative. + +## Contents + +- [Pure Feedback Mapping](#pure-feedback-mapping) +- [Mutation Feedback Without Duplicate Reporting](#mutation-feedback-without-duplicate-reporting) +- [Inline Query Failure](#inline-query-failure) +- [Server Field Error](#server-field-error) +- [Blocking And Root Failures](#blocking-and-root-failures) + +## Pure Feedback Mapping + +Map a known outcome without causing side effects: + +```ts +type Feedback = + | { + kind: "error" + message: string + } + | { + kind: "warning" + message: string + action?: { + label: string + href: string + } + } + +function getSaveFeedback(error: unknown): Feedback { + if (isApprovalRequired(error)) { + return { + kind: "warning", + message: translate("Your change was submitted for approval."), + action: { + label: translate("View request"), + href: `/requests/${error.requestId}`, + }, + } + } + + return { + kind: "error", + message: translate("Unable to save your changes."), + } +} +``` + +The renderer or feedback facade decides how to display the returned structure. +The mapper does not report the error or navigate. + +## Mutation Feedback Without Duplicate Reporting + +When the Query mutation cache already owns technical reporting: + +```ts +try { + await updateProject.mutateAsync({ + projectId, + name, + }) + + closeDialog() +} catch (error) { + const feedback = getSaveFeedback(error) + showFeedback(feedback) +} +``` + +There is no success toast because closing the dialog and updating the project +already make completion visible. + +When no central reporter observes this imperative operation: + +```ts +try { + await exportReport(reportId) + showSuccess(translate("Report exported.")) +} catch (error) { + reportError(error, { + module: "report-export", + context: { reportId }, + }) + + showError({ + message: translate("Unable to export the report."), + }) +} +``` + +## Inline Query Failure + +Keep a local failure inside the region it invalidates: + +```tsx +const content = (() => { + if (query.isPending) { + return <UiSkeleton /> + } + + if (query.isError && query.data === undefined) { + return ( + <UiAlert variant="error"> + <UiAlertTitle>{translate("Projects could not be loaded.")}</UiAlertTitle> + <UiAlertAction onClick={() => void query.refetch()}> + {translate("Try again")} + </UiAlertAction> + </UiAlert> + ) + } + + if (query.data.length === 0) { + return <ProjectsEmptyState /> + } + + return <ProjectsList projects={query.data} /> +})() +``` + +A background refetch failure with existing `query.data` should normally keep +the list visible. Add a subtle stale or retry indication only when it helps the +user. + +## Server Field Error + +Attach a known field failure to the field and stop: + +```ts +catch (error) { + if (isNameTakenError(error)) { + setError("name", { + message: translate("This name is already in use."), + }) + return + } + + showError({ + message: translate("Unable to save the project."), + }) +} +``` + +Do not also show a general toast after setting the field error. + +## Blocking And Root Failures + +Use a dialog or native alert when the user must acknowledge a consequence or +choose an action before continuing. + +Use a route, subtree, or application fallback when rendering or initialization +cannot continue. Keep the fallback stable and offer the narrowest valid +recovery action: + +```tsx +function InitializationFallback() { + return ( + <UiResult> + <UiResultTitle>{translate("The application could not start.")}</UiResultTitle> + <UiResultDescription> + {translate("Restart the application and try again.")} + </UiResultDescription> + <UiButton onClick={restartApplication}> + {translate("Restart")} + </UiButton> + </UiResult> + ) +} +``` + +Do not accompany the fallback with a duplicate toast. diff --git a/.claude/skills/api-integration/SKILL.md b/.claude/skills/api-integration/SKILL.md new file mode 100644 index 0000000..b619cbf --- /dev/null +++ b/.claude/skills/api-integration/SKILL.md @@ -0,0 +1,376 @@ +--- +name: api-integration +description: Use whenever client code integrates with any external data source or API, including REST, GraphQL, RPC, IPC or native bridges, SDKs, gateways, microservices, WebSockets, generated contracts, and mock or fixture data used to scaffold application data. Apply when creating or changing API clients, transport or auth middleware, api/modules boundaries, contract generation or inferred types, request functions, query keys, query or mutation registries, pagination, query balancing with TanStack Pacer, batching, cache updates or invalidation, and components or hooks that consume API data. +--- + +# API Integration + +Treat `api/` as the client application's boundary with external sources. Keep +transport details, external contracts, server-state identities, and cache +effects behind that boundary so feature code consumes a stable, typed module +instead of speaking directly to a transport. + +The architecture is independent of the transport: + +```text +external contract authority + -> generated or inferred contract + -> typed source client + -> source module operations + -> key registry + -> query and mutation registries + -> application consumers +``` + +OpenAPI, GraphQL code generation, a schema registry, a typed SDK, and a native +RPC bridge are different ways to supply the first three links. They do not +change the remaining shape. + +## Start by finding the real source + +Before editing: + +1. Identify every external source touched by the change. +2. Find the authoritative contract for each source. +3. Find how its client is constructed and where cross-cutting behavior lives. +4. Trace the existing key, query, mutation, and invalidation paths. +5. Check how generated artifacts are produced and validated. + +Do not invent a second integration path because a call is small. Extend the +source module that already owns the contract and transport. + +## Place integrations under `api/` + +Prefer this application boundary: + +```text +api/ + client.ts + provider.tsx + modules/ + auth/ + service-a/ + service-b/ +``` + +- Put client-side integrations with external sources under `api/`. +- When the application has multiple sources, give each source its own + `api/modules/<source>/` directory. +- A source may be a separately governed service, a gateway-backed service + surface, a third-party SDK, or a native capability. The network address is + not the boundary: multiple services behind one gateway remain separate + modules when their contracts and ownership are separate. +- Name modules after the external source or contract authority, not after a + screen, component, or user journey. +- Split a large source module internally by operation or resource when useful, + but keep one public module surface. +- Keep source-neutral infrastructure such as the shared cache client and its + provider at the `api/` root. + +Feature components must not import generated transports, raw SDKs, RPC clients, +or wire contracts directly. They consume the source module. + +## Derive types; do not curate DTOs + +Use contract types in this order: + +1. Types generated from the authoritative external contract. +2. Types inferred from runtime schemas or a typed registry. +3. Request and response types exposed by a typed SDK or RPC client. +4. Narrow aliases or projections derived from one of the above. +5. Hand-written boundary types only when no machine-readable or typed contract + exists. + +Never duplicate an external DTO by manually restating its fields. Regenerate or +re-infer it. + +Do not create a hand-maintained `types.ts` dumping ground. A `types.ts` file +written by a generator is a generated artifact, not a dumping ground: mark it +as generated, never edit it manually, and make the generator its source of +truth. Genuine client-only models should be: + +- inferred from a runtime schema when validation is required; +- colocated with the operation that owns them; +- named after their responsibility rather than placed in a generic type bag; +- kept outside the API contract when they are view, form, or component state. + +An alias is useful when it creates a stable public name for an unreadable +generated lookup. An alias that merely duplicates another local alias is not. + +## Keep mock data as scaffolding + +Treat mock data as temporary display scaffolding, not as a half-built domain +layer. Keep mock-only data behind one obvious nearest boundary: prefer the +project's established API mocking or fixture boundary; otherwise colocate it +with the source module or consumer that owns it. + +- Store literal display text and values directly in the fixture. +- Reuse real generated, inferred, SDK, or package contracts when the represented + records already exist. +- Do not invent exported domain types, fake service methods, resolvers, + registries, adapters, or parser-style utilities for functionality that does + not exist. +- Do not normalize or transform static fixture data merely to imitate a future + production integration. +- Remove or replace the mock boundary when the real source becomes available; + do not let both become competing authorities. + +## Make generation reproducible + +When contract generation is available: + +- keep the upstream contract or a deterministic contract-sync command; +- map one independently governed source to one module output; +- generate into a predictable, reviewable location; +- include a generated-file header; +- fail when required contract inputs are missing; +- detect stale outputs after sources are renamed or removed; +- expose one documented command that can be rerun locally and in CI; +- validate that regeneration leaves the worktree unchanged. + +Do not edit generated output to make a consumer compile. Fix the authority, +generator, or adapter. + +## Construct clients at the boundary + +Put shared transport concerns in an API client factory or a source-specific +client: + +- base URL or channel selection; +- authentication and refresh coordination; +- protocol-required request and response payload encoding; +- retry and timeout policy; +- transport-level logging; +- middleware or interceptors. + +Create a dedicated client when a source needs different middleware, such as an +authentication endpoint that must not invoke its own refresh interceptor. + +Do not put query keys, cache invalidation, navigation, toasts, or component +state in the transport client. The client moves typed messages; the source +module owns server-state semantics. + +## Give every cached source a stable public module surface + +For a TanStack Query integration, prefer this top-to-bottom order: + +1. Intentional public aliases derived from the contract, if needed. +2. The typed source client. +3. Plain transport operations, when reuse or focused testing warrants them. +4. `<source>Keys`. +5. `<source>Queries`. +6. `<source>Mutations`. +7. Source-local helpers. + +The registries are plain values and factories outside React. Components choose +`useQuery`, `useSuspenseQuery`, `useMutation`, prefetching, or route loading; +the API module supplies the canonical options. + +If the repository uses another server-state library, preserve the same +separation: stable identities, reusable read definitions, reusable write +definitions, and explicit post-write cache effects. + +## Build a hierarchical key registry + +Export one key registry per source module. + +- Start with an immutable `all` namespace key. +- Derive narrower keys by spreading their parent key. +- Include every argument that changes the response. +- Put stable labels before dynamic values. +- Keep key values serializable and deterministic. +- Use the same registry for queries, mutations, prefetching, cache writes, and + invalidation. +- Design useful prefixes for broad invalidation, list invalidation, and exact + entity invalidation. + +There is no arbitrary maximum key depth. The hierarchy should describe cache +identity and invalidation boundaries, not satisfy a visual limit. + +```ts +export const accountsKeys = { + all: ["accounts"] as const, + lists: () => [...accountsKeys.all, "list"] as const, + list: (filters: AccountFilters) => [...accountsKeys.lists(), filters] as const, + details: () => [...accountsKeys.all, "detail"] as const, + detail: (id: string) => [...accountsKeys.details(), id] as const, + mutations: () => [...accountsKeys.all, "mutation"] as const, +}; +``` + +## Export query definitions as a registry + +Query factories own the canonical relationship between a key and its fetch: + +```ts +export const accountsQueries = { + list: (filters: AccountFilters) => + queryOptions({ + queryKey: accountsKeys.list(filters), + queryFn: () => listAccounts(filters), + }), + detail: (id: string) => + queryOptions({ + queryKey: accountsKeys.detail(id), + enabled: Boolean(id), + queryFn: () => getAccount(id), + }), +}; +``` + +- Use `queryOptions` or the repository's equivalent so consumers share one + definition. +- Gate a query when required identity is unavailable. +- Keep UI effects out of query functions. +- Return contract data or an intentional boundary normalization, not a + component-shaped view model. +- For pagination, make the cursor or offset part of the query definition and + implement a real exhaustion condition. + +Plain transport functions are optional. Keep them when multiple query +definitions reuse an operation, non-React code calls it, or focused tests need +the transport boundary. Otherwise a typed client call may live directly in the +query function. + +## Export mutation definitions and declare cache effects + +Define reusable mutation option factories outside React: + +```ts +export const accountsMutations = { + rename: () => + mutationOptions({ + mutationKey: [...accountsKeys.mutations(), "rename"] as const, + mutationFn: renameAccount, + onSettled: (_data, _error, variables) => { + void queryClient.invalidateQueries({ queryKey: accountsKeys.lists() }); + void queryClient.invalidateQueries({ + queryKey: accountsKeys.detail(variables.id), + }); + }, + }), +}; +``` + +Every write must make its cache effects explicit: + +- invalidate the exact entity when only that entity can change; +- invalidate a list prefix when membership or ordering can change; +- invalidate the source root only when the effect is genuinely broad; +- invalidate other source modules when the write changes their data; +- update cache directly only when the returned contract data is sufficient to + do so without guessing; +- state intentionally when a mutation has no cached read effect. + +Prefer `onSettled` when the server may have changed state despite an error or +when this is the repository convention. Use `onSuccess` when the protocol +guarantees no state change on failure and avoiding a failed-write refetch is +important. Correctness determines the callback, not habit. + +Consumers own UI effects such as navigation, notifications, dialog state, and +form reset. They must not replace the module's cache lifecycle accidentally. +Prefer per-call callbacks or explicitly compose handlers when adding local +behavior. + +## Preserve transport errors + +Propagate the transport's original error value unchanged by default. Prefer the +transport's own error primitives and handling mechanisms, such as an +`AxiosError`, a Fetch `Response` or rejection, or an SDK/RPC error type. + +Do not serialize, deserialize, normalize, wrap, map, clone, or reconstruct an +error merely to create a uniform API shape. In particular: + +- do not replace a transport error with a generic `Error`; +- do not invent `ApiError`, `toApiError`, error-code maps, registries, or + conversion helpers; +- do not discard the original prototype, status, headers, body, code, cause, + retry metadata, or transport-specific guards; +- do not mutate errors while logging them; +- when a protocol returns a typed failure value rather than throwing, propagate + it as-is; throw that value directly when the query or mutation boundary + requires a rejection. + +If the project already has a specialized error registry, adapter, serializer, +or application-wide error flow, use it exactly as designed. Verify that it is +the established integration path before relying on it. Do not create or extend +such an abstraction just for the current API integration unless the task +explicitly requires changing the project's error architecture. + +Keep presentation outside the API module: components or the existing +application error flow own translated messages, toasts, and recovery UX. + +## Balance compatible queries through one batch client + +When many independent query functions target the same batch-capable source +operation, place one source-owned query balancer between them and the transport. +Let TanStack Query continue to own caching and identical-key deduplication. Let +TanStack Pacer collect distinct, temporally adjacent requests and flush them as +one transport batch. + +- Create one batcher per compatible source operation and auth, tenant, locale, + or routing context. Never choose transport context from the first queued item. +- Let every enqueue return its own promise. Preserve its `resolve` and `reject` + callbacks until the batch result is routed back to that caller. +- Flush after a short coalescing delay or when `maxSize` is reached. Treat + `maxSize` as a batch trigger, not as queue-capacity rejection. +- Route responses by array position only when the transport contract guarantees + stable ordering. Otherwise correlate them by an existing request identifier. +- Reject every affected caller with the original batch-level transport error. + Deliver per-item failure values to their matching callers unchanged. +- Keep results in TanStack Query's cache, not in the batcher. +- Avoid priority reordering unless correlation is explicit. +- Do not connect one query consumer's abort signal to a shared batch abort. +- Do not enable retries for writes or non-idempotent reads without an explicit + source guarantee. +- Expose `flush` only when a latency-sensitive caller genuinely needs it. + +Use atomic or manually constructed write batches only when the source contract +defines their ordering, partial-failure, and idempotency semantics. Invalidate +all affected cache prefixes after a successful or potentially applied write. + +Read `references/query-balancing.md` before implementing Pacer-based batching. +It contains a thin generic implementation and the lifecycle invariants that +prevent lost, crossed, or permanently pending query promises. + +## Verification + +Before finishing an API change, verify: + +- external calls remain behind `api/`; +- each source has one clear module owner; +- generated artifacts reproduce from their authority; +- no external DTO was manually duplicated; +- no hand-maintained generic `types.ts` was introduced; +- mock-only data stays behind one explicit boundary and does not introduce a + parallel domain model or fake integration layer; +- keys contain every response-changing input; +- query and mutation definitions are reusable outside components; +- each mutation's cache effects are explicit and tested; +- cross-source invalidation is covered; +- query batchers preserve one-to-one result routing and leave no caller promise + pending after success, failure, flush, or exposed cancellation; +- transport errors remain unchanged unless an existing project-wide error flow + explicitly owns their conversion; +- no new error wrapper, converter, serializer, or registry was invented; +- auth, retry, and error behavior is tested at the client boundary; +- typecheck, focused tests, and contract-generation drift checks pass. + +## References + +- Read `references/module-pattern.md` for a neutral source-module skeleton. +- Read `references/query-balancing.md` when multiple query functions can share + a source batch operation or when using TanStack Pacer. +- Read `references/transport-examples.md` when choosing how generated, + schema-inferred, SDK, or RPC contracts feed the same module architecture. + +## Related skills + +- Client state that does not mirror an external source: `state-management`. +- Query or mutation failure propagation, reporting, retry, and duplicate + suppression: `error-handling`. The no-invention rule above remains binding. +- User-visible query and mutation outcomes: `user-feedback`. +- Wiring mutations to form submission: `forms`. +- Broader source-tree ownership decisions: `file-structure`. +- Rendering loading, error, empty, and data states from an API query: + `components`. diff --git a/.claude/skills/api-integration/references/module-pattern.md b/.claude/skills/api-integration/references/module-pattern.md new file mode 100644 index 0000000..a3c9e48 --- /dev/null +++ b/.claude/skills/api-integration/references/module-pattern.md @@ -0,0 +1,157 @@ +# Source Module Pattern + +This example uses TanStack Query because it makes the registries concrete. +Replace the transport and cache adapter without changing the ownership model. + +## Suggested layout + +```text +api/ + client.ts + provider.tsx + modules/ + accounts/ + index.ts + source.ts + contract.generated.ts +``` + +`source.ts` adapts the repository's typed transport. `contract.generated.ts` is +optional: it may instead live in a generated package or be unnecessary when a +typed SDK, schema registry, or RPC client already exposes the contract. + +## Shared API infrastructure + +```ts +// api/client.ts +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient(); +``` + +```tsx +// api/provider.tsx +import { QueryClientProvider } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; + +import { queryClient } from "./client"; + +export function APIProvider({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; +} +``` + +## Source module + +```ts +// api/modules/accounts/index.ts +import { + infiniteQueryOptions, + mutationOptions, + queryOptions, +} from "@tanstack/react-query"; + +import { queryClient } from "@/api/client"; + +import { + accountsSource, + type AccountFilters, + type RenameAccountRequest, +} from "./source"; + +export const accountsKeys = { + all: ["accounts"] as const, + lists: () => [...accountsKeys.all, "list"] as const, + list: (filters: AccountFilters) => [...accountsKeys.lists(), filters] as const, + infinite: (filters: AccountFilters) => + [...accountsKeys.lists(), "infinite", filters] as const, + details: () => [...accountsKeys.all, "detail"] as const, + detail: (id: string) => [...accountsKeys.details(), id] as const, + mutations: () => [...accountsKeys.all, "mutation"] as const, +}; + +export async function listAccounts(filters: AccountFilters) { + return accountsSource.list({ filters }); +} + +export async function getAccount(id: string) { + return accountsSource.get({ id }); +} + +export async function renameAccount(variables: { + id: string; + request: RenameAccountRequest; +}) { + return accountsSource.rename(variables); +} + +export const accountsQueries = { + list: (filters: AccountFilters) => + queryOptions({ + queryKey: accountsKeys.list(filters), + queryFn: () => listAccounts(filters), + }), + infinite: (filters: AccountFilters) => + infiniteQueryOptions({ + queryKey: accountsKeys.infinite(filters), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }) => + listAccounts({ + ...filters, + cursor: pageParam, + }), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }), + detail: (id: string) => + queryOptions({ + queryKey: accountsKeys.detail(id), + enabled: Boolean(id), + queryFn: () => getAccount(id), + }), +}; + +export const accountsMutations = { + rename: () => + mutationOptions({ + mutationKey: [...accountsKeys.mutations(), "rename"] as const, + mutationFn: renameAccount, + onSettled: (_data, _error, variables) => { + void queryClient.invalidateQueries({ queryKey: accountsKeys.lists() }); + void queryClient.invalidateQueries({ + queryKey: accountsKeys.detail(variables.id), + }); + }, + }), +}; +``` + +The source adapter, contract types, keys, operations, and cache definitions form +one reviewable integration boundary. A large module may split these into named +files without changing its public surface. + +`source.ts` may wrap generated HTTP code, a schema-inferred command transport, +or a typed RPC/SDK client. It propagates transport errors unchanged unless the +project already has a specialized error flow that owns their conversion. See +`transport-examples.md`; no variant is the default. + +## Consumer + +```tsx +const account = useQuery(accountsQueries.detail(accountId)); +const renameAccount = useMutation(accountsMutations.rename()); + +function submit(request: RenameAccountRequest) { + renameAccount.mutate( + { id: accountId, request }, + { + onSuccess: () => { + showSuccess("Saved"); + closeDialog(); + }, + }, + ); +} +``` + +The per-call callback adds UI behavior without replacing the mutation +registry's cache lifecycle. diff --git a/.claude/skills/api-integration/references/query-balancing.md b/.claude/skills/api-integration/references/query-balancing.md new file mode 100644 index 0000000..566703e --- /dev/null +++ b/.claude/skills/api-integration/references/query-balancing.md @@ -0,0 +1,245 @@ +# Query Balancing with TanStack Pacer + +Use a query balancer when many independent callers request different data from +the same batch-capable source operation. The balancer coalesces nearby requests, +executes one transport call, and routes each result back to the promise created +for its original caller. + +## Contents + +- Purpose and ownership +- Minimal implementation +- Source-module integration +- Contract and lifecycle rules +- Verification + +## Purpose and ownership + +This is temporal request balancing, not server load balancing: + +```text +query A ─┐ ┌─ result A -> query A +query B ─┼─ short queue ─ batch call ─ result B -> query B +query C ─┘ └─ result C -> query C +``` + +TanStack Query and TanStack Pacer have different jobs: + +- TanStack Query caches results, deduplicates identical query keys, and manages + query lifecycle. +- TanStack Pacer groups distinct requests that arrive close together and + controls when the group executes. +- The source module owns the batch endpoint and the one-to-one mapping between + queued requests and returned results. + +Keep one long-lived balancer per batch-capable source operation. Do not create a +batcher per component or per query invocation. + +## Minimal implementation + +Use the core Pacer class for a non-React service. If the repository exposes the +same class through its framework package, follow the established import. + +```ts +import { AsyncBatcher } from "@tanstack/pacer"; + +type PendingRequest<TRequest, TResponse> = { + request: TRequest; + resolve: (response: TResponse) => void; + reject: (reason: unknown) => void; +}; + +type QueryBatcherOptions<TRequest, TResponse> = { + key: string; + maxSize: number; + wait: number; + execute: ( + requests: readonly TRequest[], + ) => Promise<readonly TResponse[]>; +}; + +export function createQueryBatcher<TRequest, TResponse>({ + key, + maxSize, + wait, + execute, +}: QueryBatcherOptions<TRequest, TResponse>) { + const batcher = new AsyncBatcher< + PendingRequest<TRequest, TResponse> + >( + async (pending) => { + const responses = await execute( + pending.map(({ request }) => request), + ); + + if (responses.length !== pending.length) { + throw new Error( + `Batch response count mismatch: expected ${pending.length}, received ${responses.length}`, + ); + } + + pending.forEach(({ resolve }, index) => { + resolve(responses[index]!); + }); + }, + { + key, + maxSize, + wait, + onError: (error, pending) => { + pending.forEach(({ reject }) => { + reject(error); + }); + }, + throwOnError: false, + }, + ); + + return { + request(request: TRequest): Promise<TResponse> { + return new Promise<TResponse>((resolve, reject) => { + void batcher.addItem({ request, resolve, reject }); + }); + }, + async flush(): Promise<void> { + await batcher.flush(); + }, + }; +} +``` + +The wrapper is intentionally thin: + +- Pacer owns collection, timing, `maxSize`, execution, and observable state. +- The wrapper owns only the caller promise and result demultiplexing. +- `throwOnError: false` prevents the ignored `addItem` promise from producing + an unhandled rejection; `onError` rejects the real caller promises with the + original error. +- The cardinality error represents a broken batch contract. It does not wrap or + convert a transport error. + +## Source-module integration + +Construct the balancer once inside the source module: + +```ts +type AccountRequest = { + id: string; +}; + +const accountDetailBatcher = createQueryBatcher< + AccountRequest, + AccountResponse +>({ + key: "accounts.detail", + maxSize: 50, + wait: 20, + execute: (requests) => accountsSource.getMany(requests), +}); + +export function getAccount(id: string) { + return accountDetailBatcher.request({ id }); +} + +export const accountsQueries = { + detail: (id: string) => + queryOptions({ + queryKey: accountsKeys.detail(id), + enabled: Boolean(id), + queryFn: () => getAccount(id), + }), +}; +``` + +Concurrent components, route loaders, and prefetches now use the same +`accountsQueries.detail` definition. They do not know whether their request was +sent alone or as part of a batch. + +If the transport returns a per-item failure envelope, deliver that envelope to +the matching caller unchanged. Let the source operation apply the project's +existing direct error handling. Do not make the generic balancer understand +domain statuses or invent a common error type. + +## Contract and lifecycle rules + +### Batch only compatible work + +All items in one batch must share: + +- source and batch operation; +- base URL or transport channel; +- authentication and tenant context; +- locale or headers that affect the response; +- retry, timeout, and consistency semantics. + +Close these dependencies over the batcher's `execute` function. Do not store a +client, session, processor, or options on every item and then use the first +item's values for the entire batch. + +### Preserve correlation + +Use positional routing only when the source guarantees response order. Do not +sort or reprioritize queued items in that case. + +When ordering is not guaranteed, send an existing correlation identifier and +route responses through a lookup: + +```ts +const pendingById = new Map( + pending.map((item) => [item.request.requestId, item]), +); + +for (const response of responses) { + pendingById.get(response.requestId)?.resolve(response); +} +``` + +Reject unmatched requests as a batch-contract violation. Never guess which +caller owns a response. + +### Preserve error identity + +- Reject every item with the original error when the entire batch call fails. +- Preserve each transport-provided per-item error or failure envelope. +- Do not serialize, normalize, or wrap errors in the balancer. +- Use an existing project-wide error flow only when the project already + requires it. + +### Keep cancellation collective + +An in-flight transport batch belongs to several callers. Aborting it because +one component unmounted would cancel unrelated queries. Do not wire an +individual query signal directly to the batcher's shared abort signal. + +If individual cancellation is required, mark or remove only that pending item +before execution and settle its promise with the original abort reason. If +exposing `cancel`, `clear`, or `reset`, settle every removed caller first; +otherwise their promises remain pending forever. + +### Retry only safe batches + +Pacer can retry a failed batch, but retrying repeats every item. Enable it only +when the whole operation is idempotent and the source defines safe retry +semantics. Keep retries off by default for mutations and mixed-effect batches. + +### Separate query balancing from write batching + +Query balancing is most useful for independent reads. A write batch has +additional atomicity, ordering, partial-success, idempotency, and invalidation +requirements. Do not send mutations through a query balancer merely because the +transport accepts an array. + +## Verification + +Test the balancer with deterministic test executors: + +- several requests inside the window produce one batch call; +- reaching `maxSize` flushes immediately; +- each caller receives the response at its own position or correlation id; +- a batch-level error rejects every caller with the same error object; +- a per-item failure reaches only its matching caller; +- a response-count or correlation mismatch settles every affected promise; +- a later batch cannot resolve promises from an earlier batch; +- explicit `flush` settles all currently queued requests; +- cancellation or clearing, when exposed, leaves no promise pending; +- retries occur only when the operation is explicitly idempotent. diff --git a/.claude/skills/api-integration/references/transport-examples.md b/.claude/skills/api-integration/references/transport-examples.md new file mode 100644 index 0000000..64442fd --- /dev/null +++ b/.claude/skills/api-integration/references/transport-examples.md @@ -0,0 +1,133 @@ +# Contract and Transport Variants + +These variants are peers. Select the one supported by the source authority; +none changes the source-module, key-registry, query-registry, mutation-registry, +or invalidation pattern. + +## Generated HTTP contract + +Use this when an OpenAPI or similar machine-readable contract is authoritative. + +```ts +import createClient from "openapi-fetch"; + +import type { components, paths } from "./contract.generated"; + +export type CreateAccountRequest = + components["schemas"]["CreateAccountRequest"]; + +const accountsClient = createClient<paths>({ baseUrl }); + +export async function createAccount(request: CreateAccountRequest) { + const { data, error } = await accountsClient.POST("/accounts", { + body: request, + }); + + if (error) { + throw error; + } + + return data; +} +``` + +The generator owns `contract.generated.ts`. The module may export readable +aliases derived from it, but must not restate DTO fields. + +The same approach applies to GraphQL, protobuf/gRPC, and other contract +generators: generate the client or types, then adapt them behind the source +module. + +## Runtime schema or registry + +Use this when the source publishes executable schemas or a typed command +registry. + +```ts +import type { resources } from "@vendor/source-registry"; +import type { z } from "zod"; + +type ListAccountsParams = z.infer< + typeof resources.accounts.list.request.shape.details +>; + +export async function listAccounts(params: ListAccountsParams) { + const response = await sendTypedCommand< + typeof resources.accounts.list + >({ + action: "get", + resource: "/accounts", + details: params, + }); + + if (response.status !== "success") { + throw response; + } + + return response; +} +``` + +Types are inferred from the registry that also validates runtime messages. +The typed failure envelope is propagated unchanged. Avoid both a parallel +hand-written DTO layer and an invented error-conversion layer. + +## Typed SDK or native RPC client + +Use this when the client method already carries request and response types. + +```ts +import { AccountsNativeModule } from "@internal/accounts/native/client"; +import { queryOptions } from "@tanstack/react-query"; + +export const accountsKeys = { + all: ["accounts"] as const, + detail: (id: string) => [...accountsKeys.all, "detail", id] as const, +}; + +export const accountsQueries = { + detail: (id: string) => + queryOptions({ + queryKey: accountsKeys.detail(id), + enabled: Boolean(id), + queryFn: () => AccountsNativeModule.getAccount({ id }), + }), +}; +``` + +No local request or response aliases are required when inference remains +readable at the call site. The typed RPC contract and client factory are the +type authority. + +## Dedicated unauthenticated client + +Some operations must not use normal source middleware. Authentication refresh +is the common example: + +```ts +const sourceClient = createAuthenticatedClient<paths>(); +const authClient = createBareClient<authPaths>(); +``` + +Keep this exception explicit inside the owning source module or client +infrastructure. Do not add component-level flags that disable middleware for +individual calls. + +## Contract-derived projection + +Client code sometimes needs a subset or combination of contract types. Derive +it instead of copying it: + +```ts +type SearchParams = NonNullable< + paths["/accounts"]["get"]["parameters"]["query"] +>; + +type AccountSummary = Pick< + components["schemas"]["Account"], + "id" | "display_name" +>; +``` + +If the shape exists only for a form or view, keep it with that form or view. +It is not part of the API contract. diff --git a/.claude/skills/code-quality/SKILL.md b/.claude/skills/code-quality/SKILL.md new file mode 100644 index 0000000..00bafaf --- /dev/null +++ b/.claude/skills/code-quality/SKILL.md @@ -0,0 +1,129 @@ +--- +name: code-quality +description: Use whenever cleaning, validating, or finishing code; fixing or preventing formatting, lint, typecheck, import-order, naming, or other repository-standard violations; deciding whether the formatter or linter should autofix mechanical issues instead of editing them by hand; or running verification commands before calling work complete. +--- + +# Code Quality + +Keep code mechanically clean through the repository's own type checker, linter, +formatter, and build tooling. Treat every rule here as a default unless the +repository's configuration says otherwise; its configuration is authoritative. + +## Verification (run before done) + +Always run the repo's typecheck, lint, format, and build before declaring a +change complete. The script runner is repo-specific; these are the SAME step +expressed two ways: + +```bash +# pnpm-based repo # bun-based repo +pnpm typecheck bun run typecheck +pnpm lint bun run lint +pnpm format bun run format:check # check; *:fix to auto-fix +pnpm build bun run build +``` + +- For mechanical formatting, import-order, and autofixable lint violations, run + the repository's configured formatter or lint-fix command before editing the + affected lines manually. +- Let the tool apply the rules it owns, then inspect its diff and fix only the + remaining semantic or non-autofixable violations by hand. +- Do not imitate formatter or linter output manually when the repository already + provides the command that produces it. +- Run from the repo's canonical directory (root or the app package — follow the + repo's scripts; some lint scripts run with `--fix --max-warnings=0`). +- Fix everything to zero warnings/errors. Do not leave a red checker. +- If the change touches native/main-process code or packaging, also run the + relevant native build/dev check — see **native-integration**. + +## TypeScript: strict and honest + +Keep strict mode on. Expect (and do not disable) at least: + +```jsonc +{ + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true +} +``` + +Typing principles: + +- Prefer `unknown` + narrowing over `any`. Narrow with `typeof`, `instanceof`, + or `in` guards before accessing properties. +- Use `enum` for named constant sets; reach for `as const` objects when an enum + is heavier than the context needs, then derive `type T = typeof X[keyof typeof X]`. +- Use `satisfies` to type-check a literal without widening its inferred type. +- Derive types from their contract source, not by hand-duplication: `z.infer` + from a Zod schema, or generated types from an OpenAPI generator — adapt to the + repo's stack (see **api-integration** for contract typing). +- Add explicit return types on exported functions. + +## Path aliases, never deep relatives + +Import through the repo's configured alias (`@/...`, `@config`, `@internal/...`). +Never reach across the tree with `../../../`. + +```ts +// Good // Bad +import { UiButton } from '@/ui/UiButton' import { UiButton } from '../../../ui/UiButton' +``` + +## Linting norms + +- No unused variables. Remove them, or prefix intentionally-unused params with + `_` (e.g. `(_event) => …`). +- No stray `console`. If a log is genuinely required, scope a single + `eslint-disable-next-line` — and prefer the repo's logger (see + **logging**). +- React Hooks rules apply; keep hook dependency lists correct. +- Let the linter sort/dedupe imports. Disable rules narrowly and only with a + reason, never blanket-disable a whole file casually. + +## Import order + +Group imports, blank-line-separated, in this order (the linter usually enforces +it): + +1. React +2. External packages +3. Internal alias imports (`@/...`) +4. Relative imports (`./...`) + +```tsx +import { useState } from 'react' + +import { useQuery } from '@tanstack/react-query' + +import { UiButton } from '@/ui/UiButton' + +import { Child } from './Child' +``` + +## Formatting + +Adopt the repo's formatter — follow its `.prettierrc` (or equivalent) rather than +re-styling by hand; let the formatter own whitespace, quotes, and class sorting. +One repo's baseline, as an example of the kind of conventions to honor: 2-space +indent, no semicolons, single quotes (incl. JSX), trailing commas everywhere, +`arrowParens: avoid`, and a Tailwind class-sorting plugin. Match whatever the +target repo configures; do not impose this baseline on a repo that disagrees. + +## Naming + +- Components: `PascalCase`. Hooks: `camelCase` with `use` prefix. +- Module-level constants: `SCREAMING_SNAKE_CASE`. +- Files: `PascalCase` for components, `camelCase` for utilities. +- CSS: kebab-case utility classes (via Tailwind). The literal-class / `cn()` + rule lives in **components**. + +## Cross-links + +- React component behavior, effect alternatives, and internal ordering — + **components**. +- Logger instead of `console` — **logging**. +- Native/main-process build checks — **native-integration**. diff --git a/.claude/skills/components/SKILL.md b/.claude/skills/components/SKILL.md new file mode 100644 index 0000000..9ed3a17 --- /dev/null +++ b/.claude/skills/components/SKILL.md @@ -0,0 +1,412 @@ +--- +name: components +description: Use whenever work creates, modifies, refactors, reviews, styles, composes, or places any React component or conditional JSX; selects or adds a Ui* primitive; ports shadcn/Base UI components; designs compound, polymorphic, Context, or controlled/uncontrolled APIs; handles parent-child layout ownership, hooks and effects, skeletons, loading/error/empty/data states, or render-prop data loaders. Covers UI primitives, common business components, and feature- or page-owned components. +--- + +# Components + +Treat the repository's existing `ui/` directory as the design-system authority. +Its `Ui*` components are local adaptations of official **shadcn** components built +on **Base UI** (`@base-ui/react`), commonly using +`class-variance-authority` (cva) and `cn()` (tailwind-merge). + +Treat shadcn as an upstream source of component code and documentation, not as a +registry-managed project structure. Preserve the local `Ui*` conventions instead +of introducing Radix, HeroUI, another primitive system, or a parallel feature-local +design system. + +## Distinguish Primitives From Product Components + +Reserve the `ui/` directory and `Ui*` prefix for reusable design primitives and +their stable specializations. + +Components that compose those primitives with product behavior are not `Ui*` +components. Name them after their business or interface responsibility and place +them at the nearest common owner that contains all of their real consumers: + +- one consumer → keep it inside that component or page boundary; +- several consumers inside one page subtree → move it only to their nearest + shared owner inside that page; +- several pages or subfeatures inside one feature → move it to that feature's + contextual `components/` boundary; +- consumers from genuinely different features or application areas → place it + in the shared business-component boundary, usually `common/`. + +Do not promote a component based on hypothetical reuse, import count, or reuse +within a single owner. Move it upward only as real consumers cross ownership +boundaries: + +```text +page/components + ↑ +resource-or-feature/components + ↑ +common +``` + +In the usual application layout, `common/` sits beside `ui/`: + +```text +src/ +├── ui/ # Ui* design primitives +└── common/ # Shared product and business components +``` + +Components in `common/` use names that describe their product or business +responsibility; the `Ui*` prefix remains reserved for primitives. Treat +`common/` as a real owner, not as a catch-all. Put only components with +demonstrated use across genuinely different ownership areas there, keep +feature- or page-private components local, and organize complex common +components recursively by responsibility. + +Follow **file-structure** for the exact directory. A private child belongs inside +its owning component or page, commonly under its contextual `components/` +folder. Do not move a component into `ui/` merely because it is reusable, +complex, or implemented as a compound component. + +The component-design rules below are not limited to `Ui*` primitives. Apply +composition, Context, controlled/uncontrolled state, effect, ownership, and +render-state guidance to product components whenever those patterns make the +component simpler and more coherent. + +## Inspect The Existing UI System First + +Before implementing or styling any component: + +1. Decide whether the responsibility is a design primitive or a product + component, then inspect the nearest current owner. +2. Locate the repository's `ui/` directory from existing imports, aliases, and + source structure so the component reuses the available primitives. +3. Inspect its filenames and search for the needed behavior, not only the exact + name you expect. +4. Read the closest existing `Ui*` primitive, its named variations, and a few + real consumers. Read stories when the repository has them. +5. Reuse the existing primitive or variation when it already expresses the + required semantics and state. +6. Add a shared primitive only when the capability is genuinely missing. + +Do not enumerate the available primitives in this skill; discover the target +repository's current UI surface every time. Do not hand-roll or restyle an +interactive control before checking whether its `Ui*` equivalent already exists. + +## Port Missing Primitives From Official Sources + +When the required primitive is absent: + +1. Read the current official shadcn documentation and the matching Base UI API. +2. Select the Base UI implementation, not a Radix or another-library variant. +3. Obtain the official source manually. Use the shadcn CLI only as an optional + way to inspect or download source; do not let it initialize, restructure, or + overwrite the application's `ui/` directory. +4. Inspect analogous local primitives to learn import paths, tokens, formatting, + exports, and file shape. +5. Port the source into the local `Ui*` layer and adapt only its integration: + local `cn`, theme tokens, icon system, `data-slot` values, import aliases, and + `Ui*` exports. +6. Preserve the upstream semantics, accessibility behavior, keyboard behavior, + state attributes, prop forwarding, and ref contract. +7. Add stories or focused tests when that is the repository's established + practice, then run the normal verification through **code-quality**. + +Never paste an upstream component directly into a feature. The local `Ui*` +adaptation becomes the reusable boundary. + +## Ui* Wrapper Convention + +A `Ui*` module adapts one or more shadcn/Base UI parts into the application's +reusable design primitive. + +- Keep internal component names aligned with upstream, then alias every public + export with the `Ui` prefix: + `export { Button as UiButton, buttonVariants as UiButtonVariants }`. +- For multi-part primitives, preserve the parts and alias each public part: + `DialogContent as UiDialogContent`, `DialogTrigger as UiDialogTrigger`, and so + on. +- Put stable `data-slot` attributes on rendered parts. Preserve upstream props + and refs instead of narrowing the primitive accidentally. +- Use cva for real variant axes and compose caller classes through `cn(...)`. +- Prefer an existing wrapper before writing a new one. Add a new `Ui*` only for a + genuinely reusable design primitive — never for one feature's one-off layout. +- Use the icon system the repo already established; do not introduce another. + +## Keep Styling At The Owning Layer + +The shared `Ui*` layer owns the visual language of controls and surfaces: +colors, typography treatments, backgrounds, borders, radii, shadows, and +hover/focus/active/disabled/invalid/ARIA state styling. + +Feature and page components should use Tailwind primarily for structure: +layout, positioning, sizing, spacing, responsive arrangement, and placement of +children. Passing `className` for those structural concerns is expected. + +Do not rebuild a primitive's visual or interaction states in a consumer. If a +visual treatment is intentional and reusable, add a clearly named variation +beside the primitive. If it is unique feature composition rather than a reusable +primitive treatment, compose existing `Ui*` parts without redefining their +owned states. + +## File & Folder Layout — base + variations + +Choose one of three shapes from the component's actual responsibility: + +**1. Self-contained primitive → a flat file** `Ui{Name}.tsx` at the ui root +(`UiCard.tsx`, `UiAlert.tsx`, `UiDialog.tsx`). It may export one component or +several upstream compound parts. + +**2. Primitive with specializations → a folder `Ui{Name}/`** named after the +component and containing: + +- **`base.tsx`** — the canonical local adaptation of the official component. It + contains the shared primitive behavior, state styles, and cva variants and + exports `Ui{Name}` plus any public variants helper. +- **Named visual variations beside `base.tsx`** — one file per stable treatment, + kebab-case named for what it is (`outline-primary.tsx`, + `ghost-destructive.tsx`, `outline.tsx`, `simple.tsx`). Each one: + - imports the original: `import { UiButton } from './base'` + - is a **`default export`** named `Ui{Name}{Variation}` (`UiButtonOutlinePrimary`, + `UiInputOutline`) + - locks a base variant and/or layers extra classes via `cn('…extra', className)`, + narrowing the props it now fixes (`Omit<ComponentProps<typeof UiButton>, 'variant'>`) + - or composes richer stable structure around `base`, such as affixes, field + chrome, or another repeated integration. +- No barrel — import the exact file: `@/ui/UiButton/base`, `@/ui/UiButton/outline-primary`. + +```tsx +// UiButton/outline-primary.tsx +import { UiButton } from './base' + +export default function UiButtonOutlinePrimary({ + className, + ...rest +}: Omit<ComponentProps<typeof UiButton>, 'variant'>) { + return ( + <UiButton + {...rest} + variant='outline' + className={cn('border-primary! text-primary bg-transparent!', className)} + /> + ) +} +``` + +**3. Bespoke/composed component (not a variant family) → `Ui{Name}/index.tsx`** +(+ assets or `components/` subparts): `UiIcon`, `UiMarkdown` (+ `styles.scss`), +`UiToaster` (+ `components/`). + +**Rule of thumb:** a recurring restyle of a `Ui*` belongs in a **named variation +file** under the component folder — not scattered as inline `className` overrides +across features. `className` does merge through `cn()` (caller overrides win), but +promote anything reused into a variation so the base stays the shared authority. + +### Keep the base free of feature-specific changes + +Adapt `base.tsx` intentionally when importing, updating, or correcting the shared +primitive itself. Do not edit it to satisfy one feature's visual request. Build +that treatment around the base through a sibling variation or composition. + +## Polymorphism (render prop, not Slot) + +Base UI primitives are polymorphic via the `render` prop / `useRender` hook + +`mergeProps` — there is no Radix `asChild`/`Slot` here. To let a caller swap the +rendered element, forward `render` or drive it with `useRender`. Ensure custom +render targets accept the forwarded ref and spread the received props onto their +underlying DOM element. Preserve correct element semantics; polymorphism is not +permission to make a button behave as a link or vice versa. + +## Compound Components + +Use the compound pattern when a primitive or business component has meaningful +parts that should compose independently while sharing one coherent behavior. +This includes shadcn-style parts such as `Select.Trigger`/`Select.Content` and +higher-level feature components decomposed into Root, Trigger, Content, +Indicator, or similar roles. + +Compound parts do not require Context by default. Use the local Context boundary +below only when the parts genuinely need shared state or behavior. + +## Context As A Local Composition Boundary + +Use Context when one coherent component, feature, or page subtree has several +parts that need the same state, derived values, or actions, and explicit props +would cause prop drilling or fragmented ownership. + +Place the Provider at the narrowest owner that contains all real consumers. +Keep state and actions in that owner and expose them through a guarded consumer +hook. Derive the context value type from its owning value hook or factory when +practical instead of declaring a parallel type manually. + +```tsx +const XContext = createContext<XState | null>(null) +const useXContext = () => { + const ctx = useContext(XContext) + if (!ctx) throw new Error('X parts must be used within X') + return ctx +} +``` + +Do not introduce Context when local state and explicit props remain clearer. Do +not use it to duplicate server state owned by the data-fetching layer or durable +client state owned by the state-management layer. + +## Controlled / Uncontrolled + +Choose the state contract that fits the component. Do not require every +component to support both modes. + +- Use an uncontrolled mode when the component can own its interaction state. +- Use a controlled mode when a parent must own that state. +- Support both when the same reusable component genuinely needs autonomous and + parent-driven usage. Accept `defaultX` for the uncontrolled seed and + `x` + `onXChange` for controlled usage; never switch modes after mount. +- Use `x !== undefined` only when `undefined` unambiguously means uncontrolled. + If `undefined` is a valid controlled value, define an explicit contract instead. + +## Avoid useEffect + +Treat `useEffect` as exceptional. Before adding one, prove that the component +must synchronize with a lifecycle that no existing declarative abstraction +already owns. The mere presence of an external source does not justify an +effect: when the source fits an async or server-state model, prefer the +repository's data-fetching layer, such as TanStack Query, so request lifecycle, +caching, retries, deduplication, and cancellation stay outside the component. + +Use an effect only as the final option for synchronization that genuinely +belongs to the component, such as an imperative browser API, a third-party +imperative library, or a subscription that cannot use `useSyncExternalStore`. +Do not use it as a general-purpose way to run component logic. + +| Instead of `useEffect` for… | Prefer | +| --- | --- | +| Fetching data | The data-fetching layer, such as TanStack Query — see **api-integration** | +| Derived or computed state | Compute during render, or use `useMemo` for expensive computation | +| Subscribing to an external store | `useSyncExternalStore` or store selectors — see **state-management** | +| Responding to a prop change | Compute during render or lift state up | +| Resetting state on prop change | A `key` prop that remounts the owned subtree | +| Handling a user action | The event handler itself | + +If a React data-flow or event-driven pattern expresses the behavior, use it +instead of adding an effect. + +## Parent Owns Placement + +A child owns its internal structure, content, internal visual treatment, and +local show/hide behavior. It must NOT own the styles that place it within its +parent: surrounding layout, positioning, sibling-dependent sizing, route-level +offsets, or dock dimensions. Apply those external layout styles through a parent +wrapper so the child renders correctly in any correctly-sized slot. + +This rule governs runtime layout and styling ownership, not source-file +placement. Locate the component itself according to **file-structure**. + +### Docked / sheet surfaces — two-layer split + +1. A **generic container** (bar/sheet) owns only open/close behavior and a content slot. +2. The **owning page or layout** applies absolute/flex/grid placement and + surrounding offsets through its wrapper. +3. The **business content** fills the provided slot without knowing the page, + shell, sibling panels, or external geometry. + +The container never decides its own position in the page. + +## Component internal ordering + +Order a component file as imports → types/interfaces → component. Inside the +component, call hooks before any conditional return, then keep computed values, +handlers, and render: + +```tsx +export function MyComponent({ title }: Props) { + const [open, setOpen] = useState(false) + const items = useMemo(() => data?.filter(item => item.active), [data]) + const handleClick = () => setOpen(true) + + return <UiButton onClick={handleClick}>{title}</UiButton> +} +``` + +## Skeletons + +A skeleton must preserve the real content's visible geometry: the same occupied +space, primary dimensions, and row/section arrangement. It does not need to copy +the real DOM or every decorative detail, but swapping in data must not cause a +layout shift. For lists, render several skeleton rows to represent a realistically +populated list. + +## Ordered Async-State Rendering + +For mutually exclusive async states in one UI region, use one ordered waterfall: +loading → error → empty → data. Base blocking loading and error states on the +absence of usable data, not on query flags alone. Existing cached or stale data +normally remains the data state during a background refetch or background error; +surface secondary status separately when the product requires it. + +Prefer an inline IIFE with sequential early returns. Avoid ternary expressions +for conditional rendering in JSX; never build nested or chained render +ternaries. They quickly obscure branch priority and turn JSX into an unreadable +conditional tree. Do not scatter conditionals that can overlap or render +contradictory states. + +```tsx +{(() => { + const hasUsableData = query.data !== undefined + + if (!hasUsableData && query.isPending) return <ListSkeleton /> + if (!hasUsableData && query.isError) return <ErrorState /> + if (!query.data?.items.length) return <EmptyState /> + return <List items={query.data.items} /> +})()} +``` + +Render shared chrome such as the header, title, and primary action once outside +the waterfall. Give separate regions separate waterfalls when they can load or +fail independently. + +## Render-Prop Data-Loading Components (abstract-requesting) + +Consider a thin render-prop requesting component when referenced entities are +loaded repeatedly across UI contexts or direct request wiring would clutter +their consumers. Let it accept the entity identity, call the existing query +options through the shared data-fetching client, and expose the query result to +the render prop. Keep loading, error, empty, and presentation decisions inline +at the call site: + +```tsx +<EntityById id={id}> + {({ data, isPending, isError }) => { + if (data === undefined && isPending) return <UiSkeleton /> + if (data === undefined && isError) { + return <UiAlert>Unable to load</UiAlert> + } + if (data === undefined) return null + return <EntityView entity={data} /> + }} +</EntityById> +``` + +Do not create one reflexively for every entity or embed presentation policy +inside it. Place it at the nearest common owner of its real consumers according +to **file-structure**. + +The render-prop boundary improves declarative composition, not network +performance by itself. Query caching, deduplication, key factories, batching, +and request balancing remain responsibilities of the shared data layer → see +**api-integration**. + +## Cross-References + +- Route/page contracts, layouts, params, and navigation → **routing**. +- Physical file placement, visibility, and ownership boundaries → + **file-structure**. +- Form state, `register`, `Controller`, and validation → **forms**. +- Query and mutation options, keys, cache updates, invalidation, batching, and + request balancing → **api-integration**. +- Durable client state, selectors, persistence, and external-store + subscriptions → **state-management**. +- User-facing labels, placeholders, accessibility text, and messages when the + application uses i18n → **localization**. +- Error propagation, reporting, retry, and recovery boundaries → + **error-handling**. +- Error, success, warning, fallback, and recovery presentation → + **user-feedback**. +- Structured diagnostics and transports → **logging**. +- Lint, formatting, typecheck, and build verification → **code-quality**. diff --git a/.claude/skills/error-handling/SKILL.md b/.claude/skills/error-handling/SKILL.md new file mode 100644 index 0000000..3e2282f --- /dev/null +++ b/.claude/skills/error-handling/SKILL.md @@ -0,0 +1,251 @@ +--- +name: error-handling +description: Use whenever work adds, changes, reviews, or debugs thrown errors, rejected promises, catch or finally blocks, retries, cancellation, fallbacks, recovery, error stacks, cause chains, lost call sites, async or RPC origins, source maps, global error listeners, React or route error boundaries, TanStack Query failure policy, incident reporting, expected domain outcomes, or decides which layer owns a failure. Trigger even when the request only mentions an unhandled rejection, swallowed error, duplicate report, misleading stack trace, crash fallback, mutation failure, background refetch failure, Sentry or OpenReplay exception capture, or whether an error should propagate. +--- + +# Error Handling + +Handle a failure once, at the narrowest boundary that can make the required +decision. Keep propagation, reporting, recovery, and user presentation separate +so that one failure does not become several logs, incidents, and messages. + +Error handling is a control-flow concern. Logging records diagnostic facts; +user feedback presents an outcome. Neither one automatically means the failure +was handled. + +## Trace The Existing Flow First + +Before changing an error path: + +1. Find where the value originates and whether it is thrown, rejected, or + returned as a typed result. +2. Trace every layer that catches, logs, reports, retries, converts, or presents + it. +3. Inspect global listeners, error boundaries, and TanStack Query cache + callbacks that may already observe it. +4. Identify the boundary that owns recovery and the boundary that owns user + feedback. +5. Preserve the repository's established reporter and provider integrations. + +Do not add a local `catch` until this trace proves what decision belongs there. + +## Classify The Outcome Before Handling It + +Do not treat every non-happy path as the same failure: + +- a successful value needs no error path; +- an expected domain outcome should remain a typed result when the contract + models it as one; +- cancellation or superseded work usually needs cleanup, not an incident; +- recoverable degradation may justify a warning and fallback; +- an operation failure may need local recovery, reporting, or both; +- an unrecoverable render or initialization failure needs a recovery boundary. + +Do not invent an `Error` subclass merely to represent a non-failure such as +`pending`, `queued`, or `requiresApproval`. Prefer the generated or +schema-derived discriminated result. Preserve an existing typed-error control +flow when the project deliberately uses one, but do not generalize it into a +new application-wide convention. + +## Give Each Boundary One Job + +Low-level transports, API functions, and reusable utilities normally preserve +and propagate failures. They do not choose toast copy, navigation, or a page +fallback. + +A boundary may catch when it can: + +- recover or select a fallback; +- retry with an explicit policy; +- translate a known domain outcome into another established contract; +- attach context required by an incident reporter; +- present feedback owned by that interaction; +- perform cleanup in `finally`; +- terminate propagation intentionally. + +If none applies, let the failure propagate. + +## Avoid Catch, Log, And Rethrow + +Do not catch only to log and rethrow: + +```ts +// Avoid: the caller, query cache, or global boundary may report it again. +try { + return await loadWorkspace(workspaceId) +} catch (error) { + log.error("Workspace load failed", error, { workspaceId }) + throw error +} +``` + +Prefer direct propagation: + +```ts +return loadWorkspace(workspaceId) +``` + +Catch and rethrow only when adding information that cannot be supplied at a +higher boundary. Prefer `cause` or the repository's established typed contract; +do not flatten the original value into a generic message. + +Never swallow silently. An intentionally ignored failure must be demonstrably +expected. Add a short comment or a `warn` with safe context when the reason is +not obvious. + +## Preserve The Failure Origin + +A `try`/`catch` does not erase an existing error stack by itself. The origin is +usually lost when code replaces the value with a new error, converts it to a +string, mutates its stack, reports only the catch location, or crosses an +async/process boundary without carrying diagnostic origin separately. + +- Rethrow the same value when no new contract is required. +- If a new error genuinely adds boundary meaning, preserve the received value + as `cause`; do not pretend the wrapper stack is the original failure stack. +- Never append consumer or call-site text to `error.stack`. Keep + `errorStack`, `cause`, and an optional `originStack` as separate facts. +- Capture an origin before starting work whose later stack cannot lead back + across a timer, event, Query execution, worker, native module, or RPC call. +- Report native or host failures in the runtime where the original error still + exists. Correlate runtimes with safe request or operation identifiers instead + of reconstructing an error in the receiving runtime. +- Treat source maps and symbolication as part of the production error path, not + optional build decoration. + +Read `references/trace-preservation.md` whenever changing catches, wrappers, +async scheduling, global reporting, native boundaries, or stack diagnostics. + +## Preserve Errors As Received + +Keep transport and domain error identity, status, code, details, and cause +available to the owner that understands them. + +Do not introduce: + +- a universal application error class; +- recursive error normalization or serialization; +- registries that must change for every new error type; +- message-string matching when a typed field exists; +- catch-all conversion merely to satisfy a local helper. + +Create an `Error` fallback only at a boundary that requires an actual `Error` +instance, such as an exception-reporting SDK or render boundary, and retain the +original value as `cause` when useful. + +An established privacy or redaction adapter may need a safe representation for +one specific sink. Keep it at that sink. If legacy code must produce a redacted +replacement `Error`, verify that it does not replace the only surviving failure +stack: retain the original locally when safe and carry its unchanged standard +stack explicitly in the sink-local error or record. Do not promote that +exception into a generic error conversion pipeline. + +## Make Incident Reporting Explicit + +Reporting means sending a failure to an incident or diagnostic backend. It is +not synonymous with logging or showing feedback. + +- Report once, at the boundary with the best safe diagnostic context. +- Keep provider SDKs behind the repository's reporting facade or + provider-owned logger transport. +- Do not turn every `logger.error()` into an incident unless the repository + explicitly defines the error level that way. When it does, reporting emits + one logger record and must not call the provider separately. +- Do not make `showError()` secretly report through a boolean option. +- Avoid reporting expected cancellation, validation failures, authorization + outcomes, or other explicitly handled states unless project policy requires + it. +- Keep identifiers and operational metadata; exclude secrets, credentials, + raw payloads, and unnecessary personal data. + +When both local logging and incident capture are needed, ensure they represent +one intentional flow rather than two independent observers reporting the same +failure. + +## Use Global Boundaries As Backstops + +Initialize global rejection and uncaught-error listeners once at the runtime +composition root. They catch failures that escaped normal ownership; they are +not a replacement for local recovery. + +Treat these listeners as terminal observers. Pass the supplied error or +rejection reason into the configured logger with automatic origin capture +disabled. When an incident provider is a logger transport, this one record must +fan out to both persistence and incident capture; do not call the provider +again from the listener. + +Use React, route, or application error boundaries to: + +- report otherwise-unhandled render failures; +- replace a broken subtree with stable fallback UI; +- offer an appropriate reset, retry, reload, or navigation action; +- isolate the smallest useful region when recovery can remain local. + +Do not use an error boundary for event-handler or awaited action failures that +the interaction boundary can handle directly. + +## Treat Retry As Product Behavior + +Retry only when the operation is safe to repeat and the policy is explicit. +Consider idempotency, attempt limits, delay, cancellation, offline behavior, +and whether the user should remain in control. + +Do not add retries merely to hide an unknown failure. Do not combine automatic +retry at several layers. One owner must decide when attempts stop and what +recovery becomes visible. + +## Coordinate TanStack Query Deliberately + +TanStack Query can observe a failure at the query or mutation function, cache, +hook, call site, and error boundary. Choose one technical reporting path and one +presentation owner. + +Read `references/tanstack-query.md` whenever work touches Query or Mutation +failure behavior, `mutateAsync`, cache callbacks, background errors, +`throwOnError`, retry, or duplicate reporting. + +## Keep User Presentation Separate + +After handling policy is decided, delegate presentation to **user-feedback**: + +- field or form errors; +- inline region errors; +- toasts, banners, dialogs, and alerts; +- success, warning, and informational outcomes; +- retry or recovery controls visible to the user. + +The same catch boundary may call the reporter and the feedback layer when it +truly owns both decisions, but the helpers themselves remain independent. + +## Verify The Result + +Before finishing: + +- trace one failure from origin to its terminal owner; +- confirm it is not logged or reported twice; +- confirm expected cancellation and domain outcomes are not incidents; +- confirm low-level code does not own product copy or UI; +- confirm the original typed error remains available where needed; +- confirm no catch, wrapper, reporter, or transport overwrites the original + error stack; +- confirm any async or cross-runtime origin was captured before the boundary and + kept separately; +- confirm production stack artifacts match the exact release or update; +- confirm retry has one owner and a stop condition; +- confirm global listeners and boundaries initialize once; +- confirm incident context contains no secrets; +- confirm the user receives one appropriate presentation, if any. + +## Related Skills + +- External transports, generated error contracts, and query or mutation + ownership → **api-integration**. +- Diagnostic records, child context, persistence, and telemetry transports → + **logging**. +- Toasts, inline messages, dialogs, fallbacks, and recovery copy → + **user-feedback**. +- Form validation and server field errors → **forms**. +- Component and async-region rendering → **components**. +- Native exception boundaries and privileged provider wiring → + **native-integration**. +- Placement of reporting modules and boundaries → **file-structure**. diff --git a/.claude/skills/error-handling/agents/openai.yaml b/.claude/skills/error-handling/agents/openai.yaml new file mode 100644 index 0000000..6ab0673 --- /dev/null +++ b/.claude/skills/error-handling/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Error Handling" + short_description: "Error ownership, reporting, and recovery" + default_prompt: "Use $error-handling to design or review error propagation, catch boundaries, reporting, and recovery behavior." diff --git a/.claude/skills/error-handling/references/tanstack-query.md b/.claude/skills/error-handling/references/tanstack-query.md new file mode 100644 index 0000000..278ee2c --- /dev/null +++ b/.claude/skills/error-handling/references/tanstack-query.md @@ -0,0 +1,195 @@ +# TanStack Query Failure Flow + +Use this reference when changing query or mutation error propagation, +reporting, retry, feedback, or Error Boundary behavior. + +## Keep The Query Function Honest + +Let a query or mutation function reject with the original transport or domain +error. Do not resolve a failure as ordinary data and do not convert every error +into a generic application type. + +The API module owns transport behavior and canonical cache effects. It does not +own page-specific feedback. + +## Choose One Reporting Observer + +`QueryCache` and `MutationCache` callbacks can provide one application-wide +diagnostic observer: + +```ts +const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError(error, query) { + if (isExpectedCancellation(error)) return + + reportError(error, { + captureOrigin: false, + module: "query", + context: { + queryFamily: String(query.queryKey[0] ?? "unknown"), + }, + }) + }, + }), +}) +``` + +This is a policy option, not a mandatory pattern. Reporting every cache error +without classification creates noise from cancellation, background refetch, +offline behavior, and expected authorization or domain outcomes. + +`captureOrigin: false` is deliberate in a declarative Query observer: the +query's rejection stack is primary, while a newly captured cache-callback stack +only identifies observation. A Mutation observer may instead receive an +earlier `originStack` captured per execution as shown below. + +Do not serialize an entire query key, mutation variables, or transport payload +into reporting context. Select only the safe identifiers needed for diagnosis. + +When cache-level reporting exists, do not report the same failure again from a +component merely because it also presents feedback. + +### Do Not Forge A Combined Stack + +A Query or Mutation error stack usually identifies the transport or domain +failure but may not identify the component or interaction that started the +operation. Keep those facts separate. + +Do not mutate the shared error: + +```ts +// Avoid: retries, observers, and reporters now see a modified error object. +error.stack += `\n--- used in ---\n${callSite}` +``` + +If a consumer origin matters, capture it before the asynchronous execution and +pass it as diagnostic metadata: + +```ts +const origin = captureTraceOrigin() + +try { + await updateProject.mutateAsync(input) +} catch (error) { + reportError(error, { + originStack: origin.stack, + context: { projectId }, + }) +} +``` + +This call-site owner replaces cache-level reporting for that same failure; the +cache may still own retry and canonical mutation lifecycle. Do not create two +incident reports. + +`meta` is suitable for stable mutation ownership or a registration stack +captured when options or a hook are created. That identifies the consumer +definition, not necessarily the later `mutate()` or `mutateAsync()` invocation. +Name it honestly, for example `registrationStack`, and do not present it as the +exact invocation stack. + +If an application requires one global MutationCache reporter with the actual +`mutate()` or `mutateAsync()` invocation chain, capture synchronously in the +global `onMutate` callback and associate the origin with that Mutation instance: + +```ts +const mutationOrigins = new WeakMap<object, TraceOrigin>() + +const mutationCache = new MutationCache({ + onMutate(_variables, mutation) { + mutationOrigins.set(mutation, captureTraceOrigin()) + }, + onError(error, _variables, _onMutateResult, mutation) { + reportError(error, { + originStack: mutationOrigins.get(mutation)?.stack, + context: { + mutationFamily: String( + mutation.options.mutationKey?.[0] ?? "unknown", + ), + }, + }) + }, + onSettled( + _data, + _error, + _variables, + _onMutateResult, + mutation, + ) { + mutationOrigins.delete(mutation) + }, +}) +``` + +Capture at the start of `onMutate`, before returning or awaiting anything. In +current TanStack Query execution, that callback is entered from the imperative +mutation call before mutation work crosses its asynchronous boundary, so its +raw stack still includes the consumer chain. The WeakMap keeps concurrent +Mutation instances isolated and releases settled entries. + +Verify this behavior against the installed TanStack Query version because +callback signatures and execution details are library contracts that may +change. A restored or resumed persisted mutation has no live caller origin; do +not invent one. + +Do not put the origin in mutation variables, use one mutable "latest origin" +slot, mutate the error, or parse a fixed number of stack lines. + +## Separate Background Failure From Empty Failure + +A background refetch may fail while usable cached data remains visible. Do not +replace that data with a full error screen or emit a global toast automatically. +Choose a subtle stale or retry indication when the product needs one. + +An initial query with no usable data may render an inline error region with a +retry action. Follow the async rendering waterfall from **components** and the +presentation rules from **user-feedback**. + +## Understand Mutation Control Flow + +`mutateAsync()` returns a promise and rejects when `mutationFn` rejects. An +ordinary `try`/`catch` can therefore classify a submit or action failure: + +```ts +try { + await updateProject.mutateAsync({ projectId, name }) + showSuccess("Project updated") +} catch (error) { + showError(error, { + fallbackMessage: "Unable to update the project", + }) +} +``` + +`throwOnError` controls whether a stored mutation error is propagated during +render to an Error Boundary. It is not required for `mutateAsync()` to reject. + +The callback-based `mutate()` does not return an awaitable failure. Use its +callbacks when that style already owns the interaction; do not wrap it in +`try`/`catch` and expect the asynchronous error there. + +## Keep Lifecycle Ownership Stable + +- Keep canonical invalidation and cache updates in reusable mutation options + owned by **api-integration**. +- Keep interaction-specific success and failure feedback at the call site. +- Do not spread mutation options and silently replace their lifecycle + callbacks. +- Do not duplicate retry between TanStack Query, a transport interceptor, and + the component. +- Use Error Boundary propagation only for failures the local interaction + cannot or should not recover from. + +## Test The Policy + +Cover at least: + +- initial failure without data; +- background failure with cached data; +- expected cancellation; +- mutation rejection handled at the call site; +- repeated or concurrent mutations without origin cross-contamination; +- retry without repeated mutation of the same error stack; +- one technical report for one failure; +- retry exhaustion and recovery. diff --git a/.claude/skills/error-handling/references/trace-preservation.md b/.claude/skills/error-handling/references/trace-preservation.md new file mode 100644 index 0000000..a975c17 --- /dev/null +++ b/.claude/skills/error-handling/references/trace-preservation.md @@ -0,0 +1,180 @@ +# Error Trace Preservation + +Use this reference when a catch, wrapper, async boundary, reporter, or native +call makes a failure point to infrastructure instead of its real origin. + +## Contents + +- [Catch Is Not The Loss](#catch-is-not-the-loss) +- [Propagation And Wrapping](#propagation-and-wrapping) +- [Async Origins](#async-origins) +- [Process And Native Boundaries](#process-and-native-boundaries) +- [Reporting](#reporting) +- [Framework Boundaries](#framework-boundaries) +- [Verification](#verification) + +## Catch Is Not The Loss + +An existing `Error` normally keeps the stack captured when it was created. +Merely entering a `catch` does not replace it: + +```ts +try { + return await loadProject() +} catch (error) { + throw error +} +``` + +This catch is still unnecessary when it makes no decision, but it does not by +itself reset an `Error` stack. + +The destructive versions are: + +```ts +catch (error) { + throw new Error(String(error)) +} + +catch (error) { + reportError(new Error("Project failed")) +} + +catch (error) { + error.stack += `\n${new Error().stack}` +} +``` + +They replace identity, discard typed fields or cause, or corrupt the original +stack. + +## Propagation And Wrapping + +Propagate the received value unchanged unless a boundary owns a real contract +translation. + +When a wrapper adds essential meaning, retain the original: + +```ts +try { + await storage.open(projectId) +} catch (error) { + throw new Error("Opening project storage failed", { + cause: error, + }) +} +``` + +The wrapper stack and cause stack describe different layers. Do not concatenate +them into one string. Let the established incident provider represent the cause +chain when it supports it; otherwise retain the original cause locally and add +small safe context explicitly. + +Do not create a wrapper only to change wording for a log or user message. Logger +messages and user feedback can add context without replacing the failure. + +## Async Origins + +An error stack describes where the failure occurred. It may not show who +scheduled the work after a timer, event, queue, Query execution, worker, or RPC +boundary. + +Capture a supplemental origin synchronously before crossing that boundary: + +```ts +const origin = captureTraceOrigin() + +runInBackground() + .catch(error => { + reportError(error, { + originStack: origin.stack, + context: { operationId }, + }) + }) +``` + +Keep the origin outside the error object. Capture one origin per execution so +parallel work cannot overwrite another operation's diagnostic. + +Do not capture inside the later `catch` and label it as the operation origin. It +only identifies the observer. + +## Process And Native Boundaries + +Do not expect an `Error` instance to preserve class identity, non-enumerable +fields, cause, and stack through browser workers, Electron IPC, native modules, +or another RPC implementation. + +Choose ownership before transport: + +- report a host/native failure in the host/native runtime while the original + error exists there; +- report a renderer/JavaScript failure in that runtime before sending a + persistent log record; +- send minimal standard diagnostic strings only when another runtime must + persist or display them; +- use a request or operation identifier to correlate the two runtime records. + +Never recreate a received string as `new Error(receivedMessage)` and present its +receiver-side stack as the remote failure origin. + +## Reporting + +Pass the original error to the incident reporter whenever it accepts the value. +Attach safe module, operation, request, and supplemental origin information +through the provider's scope or context API. + +If a reporter requires an `Error` but the thrown value is not one, create a +fallback only at that reporter boundary: + +```ts +const reportable = + error instanceof Error + ? error + : new Error(message, { cause: error }) +``` + +That fallback stack identifies the conversion boundary. Do not describe it as +the original failure location. + +Avoid global `Error.prepareStackTrace` overrides and stack-string rewriting. +They are engine-specific and can interfere with source-map and incident +provider processing. + +An established redaction adapter is another replacement boundary. If it creates +a safe `Error` instance or record for one sink, preserve the received error in +the local flow when safe and copy its unchanged standard stack explicitly into +that sink-local diagnostic shape. A newly constructed redacted error otherwise +points to the sanitizer, which makes the privacy layer look like the failure +origin. + +## Framework Boundaries + +- TanStack Query cache callbacks are observers. Preserve the rejection and use + explicit ownership metadata; read `tanstack-query.md` for exact invocation + origins. +- Disable automatic logger-origin capture in observers that have no earlier + operation origin. Their stack is still available when intentionally needed, + but it must not be mislabeled as the caller. +- React error boundaries receive an error stack and a component stack. Preserve + both as separate diagnostic fields. +- Global `error` and `unhandledrejection` listeners are terminal backstops. Use + the supplied `Error` or rejection reason; do not replace it merely to add the + words "Unhandled error". +- A retry is another execution, not permission to append another section to the + same error stack. + +## Verification + +For each changed flow, record: + +1. where the original error is created; +2. which layer first catches it; +3. which layer owns recovery or reporting; +4. which discontinuity requires a supplemental origin; +5. which runtime retains the original error; +6. how production frames map to the exact shipped artifact. + +Force the same failure through retries and concurrent executions. Confirm that +the original stack remains byte-for-byte unchanged and each execution retains +its own origin. diff --git a/.claude/skills/file-structure/SKILL.md b/.claude/skills/file-structure/SKILL.md new file mode 100644 index 0000000..d4dd483 --- /dev/null +++ b/.claude/skills/file-structure/SKILL.md @@ -0,0 +1,246 @@ +--- +name: file-structure +description: Use whenever work creates, moves, renames, deletes, splits, merges, or relocates files or directories; introduces a module or folder; adds or expands barrel files; changes which files are public or private through entrypoints or package exports; or requires deciding where code should live. Do not use for content-only edits that preserve existing file placement. +--- + +# File Structure + +Keep the source tree aligned with real ownership. Apply this skill before making +structural changes, not after files have already been placed. + +## Inspect Before Placing + +Read the affected tree before deciding where anything belongs: + +1. Find the nearest existing files with the same responsibility. +2. Inspect local naming, co-location, entrypoint, test, and export conventions. +3. Trace current consumers and package exports before moving or hiding a file. +4. Prefer the repository's coherent existing convention. Do not introduce a + parallel structure merely because another project uses it. +5. If nearby conventions conflict, follow the boundary that best matches actual + ownership and keep the change scoped. Do not reorganize unrelated code. + +## Apply Recursive Encapsulation + +Treat every directory that represents a module as the same recursive structure: + +- its entrypoint is the public boundary seen by consumers outside that directory; +- files owned only by that module live inside the directory, behind the boundary; +- an owned child may become a module with its own entrypoint and private + implementation; +- repeat the same public-outside, private-inside rule at every depth. + +This is about visibility and ownership, not file kind. Apply it equally to +components, hooks, schemas, services, adapters, formatters, fixtures, tests, +state, and any other implementation. + +In this model, consumers sit outside or above the boundary they consume, while +owned implementation goes inward or below it. "Above" and "below" describe the +ownership tree; they do not require every consumer to be located in a literal +parent directory. + +Place every file at the narrowest boundary that contains all current consumers: + +- one file or module only: keep it inside that owner's boundary; +- several files in one feature: keep it at that feature boundary; +- sibling features or pages: place it at their nearest shared owner; +- unrelated features or packages: promote it only when multiple real consumers + require the same contract or behavior. + +Do not promote code for hypothetical reuse. The second real use is evidence to +reconsider placement, not an automatic reason to create a global abstraction. +When consumers change, move the file to their new narrowest common owner instead +of exporting a private path merely to avoid relocation. + +Keep supporting files near the module they verify or support unless the +repository has a stronger established convention. + +## Name Child Folders from Their Context + +Name an owning module by its capability, domain, or responsibility. Inside that +owner, use the conventional role folder that best explains the children: +`components/`, `hooks/`, `schemas/`, `services/`, `adapters/`, `formatters/`, +`fixtures/`, `tests/`, or the repository's established equivalent. + +These names are contextual, not global categories. `Feature/components/` means +components private to Feature; `Feature/hooks/` means hooks private to Feature. +Do not lift them into a broad root-level dump merely because several files share +the same technical kind. + +Avoid ambiguous catch-all locations such as `misc/`, overly broad `helpers/`, +overly broad `utils/`, `common/`, `shared/`, or a global `types.ts` when their +contents do not form one coherent boundary. Prefer a precise contextual role, +direct co-location, or the actual capability name. + +Do not create a directory for a single leaf file unless the directory establishes +a real module boundary, owns private children, or follows a required repository +convention. Promote a growing leaf to a folder without changing its public import +surface when practical. + +## Separate Public Surface from Implementation + +Structure packages and substantial modules by visibility: + +- expose only intentional, stable entrypoints at the public boundary; +- use an `internal/` directory only when it represents a real package- or + application-level non-public boundary and the repository uses that model; +- organize `internal/` by capability rather than turning it into another dump; +- use each internal module's entrypoint as its local boundary; +- do not expose internal implementation again through package subpath exports; +- keep platform-specific entrypoints separate when consumers genuinely need + different runtime surfaces. + +Do not create `internal/` inside every owner. A file is already private when it +lives inside its owner's directory and is absent from that owner's public +entrypoint. Use the contextual role folders above, or keep a private leaf beside +the entrypoint. + +## Minimize Barrels + +Prefer direct imports. Do not add an `index.ts` to every directory merely because +the directory exists. + +Distinguish three different uses: + +- an implementation entrypoint such as `Component/index.tsx`, where the file + defines or composes the public component, is not a barrel; +- a small curated public surface at a real package or feature boundary may + re-export a few intentional capabilities; +- a convenience file that mechanically re-exports directory contents is a + barrel and should normally not exist. + +Allow a barrel only when the boundary is real, the exported set is small, +cohesive, stable, and immediately obvious, and consumers should not know the +internal paths. There is no useful numeric limit: aim for one curated surface per +real public module, not one barrel per folder. + +Inside private implementation, import the owning file directly. Never: + +- create chains where one barrel imports from or re-exports another barrel; +- use `export *` to sweep a directory into an API; +- re-export private files merely to shorten an import path; +- combine unrelated capabilities behind one convenience entrypoint; +- keep expanding a barrel after the origin and ownership of its exports stop + being obvious. + +When barrels begin multiplying, replace convenience re-exports with direct +imports before the dependency graph becomes opaque or cyclic. + +## Recursive Structure Examples + +Choose the shape from the current owner and its children. These examples express +the same rule in different contexts; they are not mandatory folder names. + +### Component ownership + +```text +Workspace/ +├── index.tsx +├── hooks/ +│ └── use-workspace-state.ts +└── components/ + ├── Canvas.tsx + └── Toolbar/ + ├── index.tsx + ├── hooks/ + │ └── use-toolbar-shortcuts.ts + └── components/ + └── ToolbarAction.tsx +``` + +`use-workspace-state.ts` is shared by Workspace children. The shortcut hook and +action component are private to Toolbar and therefore live inside Toolbar. + +### Composed hook + +```text +hooks/ +└── use-selection/ + ├── index.ts + └── hooks/ + ├── use-keyboard-selection.ts + └── use-pointer-selection.ts +``` + +The surrounding feature imports `use-selection/index.ts`. Its composing hooks +stay behind that hook module's boundary. + +### Recursive feature ownership + +```text +FileUpload/ +├── index.tsx +├── components/ +│ ├── Dropzone/ +│ │ ├── index.tsx +│ │ └── hooks/ +│ │ └── use-drag-state.ts +│ └── UploadProgress.tsx +├── hooks/ +│ ├── use-upload-progress.ts +│ └── use-upload-queue.ts +└── validators/ + ├── file-size.ts + └── file-type.ts +``` + +The root `hooks/` and `validators/` contain behavior shared inside FileUpload. +`use-drag-state.ts` belongs only to Dropzone, so it moves into that component's +own boundary. The same rule therefore repeats inside the feature without an +undifferentiated `internal/`. + +### Single private leaf + +```text +Summary/ +├── index.tsx +└── format-total.ts +``` + +Do not create `internal/`, `helpers/`, or `formatters/` for one private leaf +unless local convention requires it. Direct co-location already communicates +ownership. + +### Real package-level internal boundary + +```text +package/src/ +├── index.ts +├── native.ts +└── internal/ + ├── protocol/ + │ └── index.ts + └── runtime/ + └── index.ts +``` + +Here `internal/` is meaningful: package consumers may import only the declared +public entrypoints, while several non-public capabilities remain package-owned. + +Outsiders import the public module, not its private children. Do not expose an +internal file merely because another internal file needs it; place both under +their narrowest common owner. + +When any leaf gains private children, promote it to a folder with an entrypoint +and continue the same structure recursively. + +## Defer Route And Page Trees To Routing + +When a structural change creates, moves, or reorganizes route branches, page +boundaries, route declarations, or route-tree assembly, apply **routing** as the +authoritative structure. Use this skill recursively for the non-routing +implementation inside the page or layout boundary selected by **routing**. + +## Make Structural Changes Complete + +When moving, renaming, splitting, merging, or deleting files: + +1. Update all imports, aliases, entrypoints, package exports, tests, fixtures, + tooling configuration, and generated registries that reference the old path. +2. Move the implementation; do not leave duplicate old and new copies unless a + deliberate compatibility layer is required. +3. Preserve public import paths when the public contract is not meant to change. +4. Remove obsolete empty structure only when it is fully owned by the change. +5. Search for the old path and old exported names after the move. +6. Run the repository's focused structural verification, then its required + typecheck, lint, tests, and build as appropriate. diff --git a/.claude/skills/file-structure/agents/openai.yaml b/.claude/skills/file-structure/agents/openai.yaml new file mode 100644 index 0000000..d1349ef --- /dev/null +++ b/.claude/skills/file-structure/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "File Structure" + short_description: "Organize files by ownership and visibility" + default_prompt: "Use $file-structure to place files behind the narrowest correct module boundary." diff --git a/.claude/skills/forms/SKILL.md b/.claude/skills/forms/SKILL.md new file mode 100644 index 0000000..f3761de --- /dev/null +++ b/.claude/skills/forms/SKILL.md @@ -0,0 +1,249 @@ +--- +name: forms +description: Use whenever work creates, changes, reviews, or debugs a form, form field, input flow, validation schema, submit flow, create or edit form, server field error, react-hook-form register or Controller integration, or Zod form schema. Trigger even when the request only describes a product form such as sign-in, settings, search, filters, a dialog form, or an editable resource without naming the form libraries. +--- + +# Forms + +Build forms with `react-hook-form` + Zod and treat each form as a thin, +declarative boundary around rendered fields and submission. + +A field control may be: + +- an existing `Ui*` design primitive; +- a composition of several primitives; +- a product component with its own meaningful controlled or uncontrolled API. + +Inspect the repository's existing UI and product components before creating a +new control. Follow **components** for primitive reuse, product-component +ownership, and controlled/uncontrolled component contracts. + +## Schema and Types + +- Define the Zod schema close to the form unless the same form contract is + genuinely shared. Colocation beats premature extraction. +- The form schema mirrors the fields represented by the rendered form. It is + the readable inventory, validation contract, and value contract for that UI. +- Type form values with `z.infer<typeof formSchema>`. Never hand-maintain a + parallel `FormValues` interface. +- Wire validation through `zodResolver(formSchema)`. +- A form schema is not an API DTO and must not be shaped around one. Do not + create API types, proxy types, or DTO schemas inside the form. +- Give every stable field an explicit initial value through `defaultValues`. + Never use `undefined` as the value of a controlled field or `Controller`. +- Treat browser-managed file inputs separately. For conditionally mounted or + dynamically registered fields, deliberately choose registration, + unregistration, and default-value behavior instead of relying on omission. + +## Create And Edit Forms + +One form component may support both creation and editing by accepting an +optional existing entity: + +- entity present → edit mode; +- entity absent → create mode. + +Absence must mean create mode only. The owner must resolve loading, error, and +not-found states before mounting an edit form; do not temporarily render create +mode while an entity is still loading. + +Populate `defaultValues` explicitly from the entity: + +```ts +const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: entity?.name ?? "", + description: entity?.description ?? "", + }, +}); +``` + +Select only fields represented by the form. Do not spread the entire entity +into `defaultValues`. + +Treat these defaults as the initial snapshot for that form instance. When the +owner switches to a different entity, remount the form by identity: + +```tsx +<EntityForm key={entity?.id ?? "create"} entity={entity} /> +``` + +Key by stable identity, not by the entity object or all of its values. A +background query update for the same entity must not silently overwrite dirty +user input. + +Create both mutation hooks unconditionally, as required by React's hook rules, +and choose the operation inside `handleSubmit`: + +```ts +const onSubmit = handleSubmit(async (values) => { + if (entity) { + await updateMutation.mutateAsync({ + id: entity.id, + name: values.name, + description: values.description, + }); + return; + } + + await createMutation.mutateAsync({ + name: values.name, + description: values.description, + }); +}); +``` + +Do not use `useEffect` to copy entity data into form state. After a successful +update, call `reset()` with the committed form values only when the form remains +open and should establish a new pristine baseline. A form that closes or +navigates away does not need that reset. + +## Numeric and Coerced Inputs + +- HTML inputs usually produce strings. Decide explicitly what an empty value + means before converting it. +- Use `z.coerce.number()` only when its empty-string and coercion behavior is + correct for that field. Remember that `Number("")` is `0`. +- Prefer a deliberate `z.preprocess`, `register(..., { valueAsNumber: true })`, + or `setValueAs` when empty, optional, and invalid values need different + semantics. +- Apply the same discipline to dates, booleans, selects, and any other control + whose rendered value differs from its form value. + +## Validation + +- Per-field rules live on the field schema with a user-facing message + (`z.string().trim().min(1, "...")`). +- Cross-field rules use `.refine()` / `.superRefine()` and **must set `path`** so the + error attaches to the right field (e.g. confirm-password mismatch -> `path: ["confirmPassword"]`). + +## Submit and Pending State + +- Prefer one `handleSubmit(async (values) => { ... })` as the complete + form-submission flow. Keep request preparation, `mutateAsync`, field-error + handling, and success UX readable in that handler. +- Call the typed mutation with an explicit object built directly from form + values: + +```ts +const onSubmit = handleSubmit(async (values) => { + await mutation.mutateAsync({ + email: values.email, + password: values.password, + }); +}); +``` + +The mutation's typed parameter is the API contract and verifies the object. +Do not create form-to-DTO mappers, conversion helpers, proxy types, or local API +schemas. Form-only fields simply do not appear in the mutation call. + +- Use `mutateAsync` so the submit handler can await the operation and express + success and failure in normal control flow. +- `mutateAsync` rejects when the mutation function rejects, so an ordinary + `try`/`catch` handles submit failures without `throwOnError`. That option + controls render-phase propagation to an Error Boundary; do not enable it just + to make form error handling work. If the mutation function resolves an error + as a successful value or otherwise swallows it, correct or follow the + established contract in **api-integration** rather than compensating inside + the form. +- Compute one pending flag: + `isPending = formState.isSubmitting || mutation.isPending`. +- Drive duplicate-submission prevention and the submit control's pending + presentation from that flag. +- Keep canonical invalidation and API-wide mutation behavior inside the API + module's `mutationOptions`. See **api-integration**. +- Prefer local success UX directly after the awaited mutation: show feedback, + `reset()`, navigate, or close the form. +- Do not spread canonical mutation options and then overwrite their + `onSuccess` or `onError` callbacks in the form. + +## register vs Controller + +- **`register()` by default.** Native-input `Ui*` wrappers (`UiInput`, `UiTextarea`) + expose a native `value`/`onChange`/`ref` contract and bind directly: `{...register("email")}`. +- **`Controller` only when a component lacks a compatible native input + contract.** Custom primitives such as selects, switches, checkboxes, radio + groups, and segmented controls commonly need it. +- Product components may also act as fields. When their state must be owned by + the form, compose `Controller` with the component's controlled API + (`value`/`onValueChange` or its semantic equivalent). Do not move RHF into the + product component merely to make it usable by a form. +- Compose react-hook-form's `<Controller>` directly with the existing primitive + or product component at the field call site. +- Never create or reuse `ControlledUi{X}` components, `controlled.tsx` modules, or + reusable wrappers around `useController`. Keep RHF ownership visible in the form and + keep the `Ui*` layer independent of the form library. + +## Field Composition + +Prefer the established shadcn-style field composition when the project provides +it: a field container groups its label, control, optional description, and +validation error. The container owns field-level layout and invalid state; the +control keeps its own visual and interaction contract. + +This is a recommendation, not a mandatory component tree. Inspect existing +forms and the repository's UI primitives before choosing exact components, +names, props, or ordering. + +An adapted shadcn `Field` composition may look like: + +```tsx +<UiField data-invalid={!!errors.email}> + <UiFieldLabel htmlFor="email">Email</UiFieldLabel> + <UiInput + {...register("email")} + id="email" + type="email" + autoComplete="email" + aria-invalid={!!errors.email} + /> + {errors.email?.message && ( + <UiFieldError errors={[errors.email]} /> + )} +</UiField> +``` + +The names are illustrative. Use the project's equivalent rather than creating +these wrappers solely to match the example. Never restyle a base control to make +it fit a form; compose around it. + +## Server Field Errors + +- Catch submit failures in the `handleSubmit` callback when the form needs to + classify them. +- When the API returns a field-specific failure, attach it with + `setError(field, { message })`, then return. +- Detect the specific failure from the typed transport error (status/code/detail), not by + string-matching a generic message. Transport errors are thrown as received and preserve + their status/message/detail (owned by **api-integration**) — exploit that here instead of + flattening. +- General submit failures surface through the project's feedback flow, not + `setError`. +- Use one presentation path for each failure. Do not show a general mutation + error and then show the same failure again as a field error. + +## Cross-References + +- `Ui*` primitives, product fields, field-error presentation, and + controlled/uncontrolled component APIs → **components**. RHF `Controller` + composition remains owned here. +- `mutationOptions`, query/mutation key factories, and cache invalidation placement + -> **api-integration** (separate skill from state management). +- Submit success and failure feedback (`showError` / `showSuccess` or the + repository's equivalent) -> **user-feedback**. +- Submit failure propagation, reporting, retry, and Error Boundary policy -> + **error-handling**. +- Page/feature placement of the form module and where its files live -> **file-structure**. + +## Anti-Patterns + +- No `useEffect` to sync derived form state — derive in render or via watched values. +- No restyling base UI components to make a field fit; wrap them. +- No ternary expressions for conditional JSX. Use a simple logical condition + for one optional element and an ordered IIFE for multiple render branches. +- No form-to-DTO mapper layer, local API types, or duplicate API schemas. + +See `references/patterns.md` for terse, stack-specific patterns that should be +adapted to the repository's existing components and contracts. diff --git a/.claude/skills/forms/references/patterns.md b/.claude/skills/forms/references/patterns.md new file mode 100644 index 0000000..cb39511 --- /dev/null +++ b/.claude/skills/forms/references/patterns.md @@ -0,0 +1,172 @@ +# Form Patterns + +Stack-specific, adaptable skeletons for react-hook-form, Zod, TanStack Query, +and a shadcn-style component system. Replace component and feedback names with +the repository's established equivalents. + +## (a) Basic Form + Mutation Submit with isPending + +```tsx +const formSchema = z.object({ + title: z.string().trim().min(1, "Title is required"), + quantity: z.number().positive("Must be greater than 0"), +}); +type FormValues = z.infer<typeof formSchema>; + +function ResourceForm() { + const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = + useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { title: "", quantity: 1 }, + }); + + const mutation = useMutation(createResourceMutationOptions()); + const isPending = isSubmitting || mutation.isPending; + + const onSubmit = handleSubmit(async (values) => { + // mutateAsync rejects when mutationFn rejects. throwOnError is only for + // render-phase Error Boundary propagation and is not required here. + try { + await mutation.mutateAsync({ + title: values.title, + quantity: values.quantity, + }); + + showSuccess("Saved"); + reset(); + } catch (error) { + showError(error, { fallbackMessage: "Unable to save" }); + } + }); + + const submitLabel = (() => { + if (isPending) return "Saving..."; + return "Save"; + })(); + + return ( + <form onSubmit={(e) => void onSubmit(e)}> + <UiInput + {...register("quantity", { valueAsNumber: true })} + type="number" + aria-invalid={!!errors.quantity} + /> + <UiButton type="submit" disabled={isPending}> + {submitLabel} + </UiButton> + </form> + ); +} +``` + +## (b) Cross-Field Refine (set path) + +```ts +const schema = z + .object({ + password: z.string().min(8), + confirmPassword: z.string().min(1, "Confirm your password"), + }) + .refine((v) => v.password === v.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], // attach error to the right field + }); +``` + +## (c) Build The Typed Mutation Parameter In handleSubmit + +```ts +const onSubmit = handleSubmit(async (values) => { + await signUpMutation.mutateAsync({ + email: values.email, + password: values.password, + }); +}); +``` + +`confirmPassword` remains part of `formSchema` because it is rendered in the +form, but it is simply not included in the typed mutation argument. + +## (d) Server Field Error via setError + +```ts +const onSubmit = handleSubmit(async (values) => { + try { + await mutation.mutateAsync({ + username: values.username, + password: values.password, + }); + } catch (error) { + if (isFieldTakenError(error)) { // detect from typed transport error + setError("username", { message: "Username is already taken" }); + return; // handled — do not rethrow + } + showError(error, { fallbackMessage: "Unable to save" }); + } +}); +``` + +## (e) Controller Fallback (no native input contract) + +```tsx +// Use for primitives or product fields that expose a controlled value contract. +<Controller + control={control} + name="enabled" + render={({ field }) => ( + <UiSwitch checked={field.value} onCheckedChange={field.onChange} /> + )} +/> +``` + +## (f) Shared Create / Edit Form + +Mount edit mode only after its entity is available. Remount when its identity +changes: + +```tsx +<EntityForm key={entity.id} entity={entity} /> +``` + +Use a distinct create-mode instance: + +```tsx +<EntityForm key="create" /> +``` + +Inside the shared form: + +```tsx +function EntityForm({ entity }: { entity?: Entity }) { + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: entity?.name ?? "", + description: entity?.description ?? "", + }, + }); + + const createMutation = useCreateEntityMutation(); + const updateMutation = useUpdateEntityMutation(); + + const onSubmit = form.handleSubmit(async (values) => { + if (entity) { + await updateMutation.mutateAsync({ + id: entity.id, + name: values.name, + description: values.description, + }); + return; + } + + await createMutation.mutateAsync({ + name: values.name, + description: values.description, + }); + }); + + // Render the same form fields for both modes. +} +``` + +Do not synchronize later entity updates into the form with `useEffect`. diff --git a/.claude/skills/localization/SKILL.md b/.claude/skills/localization/SKILL.md new file mode 100644 index 0000000..1ed1b71 --- /dev/null +++ b/.claude/skills/localization/SKILL.md @@ -0,0 +1,500 @@ +--- +name: localization +description: Use whenever work adds, changes, reviews, or debugs user-facing copy in an application that uses localization, including visible text, labels, placeholders, accessibility text, validation messages, notifications, translation calls, source-text or semantic keys, locale resources, interpolation, plurals, context variants, typed translation APIs, enum labels, locale switching, or translation linting. Trigger even when the request mentions only changing UI wording without explicitly naming i18n. +--- + +# Localization + +When an application uses localization, every user-facing message goes through +its translation layer: + +- visible text and actions; +- labels, descriptions, placeholders, and hints; +- empty, loading, success, and error messages; +- validation feedback and notifications; +- `aria-label`, image `alt`, and other accessibility text. + +An application without localization should not receive a speculative partial +i18n layer. Adopt this skill when localization already exists, is being +introduced, or a translatable-string audit is explicitly requested. + +Inspect the installed localization library, initialization, source locale, +resource files, translator APIs, plural/context conventions, and validation +commands before changing copy. + +## Use Source Text As The Default Key + +For ordinary UI copy, use the canonical source-language text itself as the +translation key: + +```ts +translate("Cancel"); +translate("Create a new project"); +translate("Changes are saved automatically"); +``` + +The source catalog repeats the source text as its value: + +```json +{ + "Cancel": "Cancel", + "Changes are saved automatically": "Changes are saved automatically", + "Create a new project": "Create a new project" +} +``` + +This makes the application searchable from its interface: copying visible text +and searching the repository should lead directly to the resource and ordinary +call sites. + +Do not replace ordinary copy with invented page-tree, component, kebab-case, or +UI-role identifiers such as: + +```text +projects.details.header.delete-project-btn +settings.notifications.description-body +``` + +File location and presentation role are not stable message identities. Moving a +component or changing a label into a button must not rename its translation. + +Use the exact source message, including meaningful capitalization and +punctuation. A wording change creates a new source key and requires existing +translations to be reviewed. Remove the obsolete key after migrating every call +site. + +## Reuse Meaning, Disambiguate Context + +Repeated source text is not inherently a collision. + +- Same text and same meaning → reuse one key. +- Same text but different meaning or required translation → disambiguate it. + +Prefer the localization engine's context feature: + +```ts +translate("Open", { context: "action" }); +translate("Open", { context: "state" }); +``` + +Store the base message and the engine's contextual variants in every locale. +For example, i18next uses its configured `contextSeparator` (`_` by default): + +```jsonc +// source locale +{ + "Open": "Open", + "Open_action": "Open", + "Open_state": "Open" +} +``` + +```jsonc +// another locale +{ + "Open": "Відкрити", + "Open_action": "Відкрити", + "Open_state": "Відкрито" +} +``` + +The call site passes the unsuffixed source key and semantic context. The +localization engine resolves the appropriate contextual resource key: + +```text +translate("Open", { context: "action" }) -> Open_action +translate("Open", { context: "state" }) -> Open_state +``` + +Do not append the context suffix manually at call sites. Follow the installed +engine's exact separator, fallback, typing, and plural/context composition +rules. Keep a base entry when the engine uses it as the non-contextual fallback. + +Context describes linguistic meaning, not file placement. Add a translator +comment when the library or catalog supports one. + +If the established runtime has no context mechanism, use the project's explicit +disambiguation convention while keeping the source text searchable, for +example: + +```ts +translate("Open|action"); +translate("Open|state"); +``` + +```jsonc +// source locale +{ + "Open|action": "Open", + "Open|state": "Open" +} +``` + +```jsonc +// another locale +{ + "Open|action": "Відкрити", + "Open|state": "Відкрито" +} +``` + +Do not invent a delimiter or alternate context system when the project already +has one. + +Short words such as `"Save"`, `"Cancel"`, `"Continue"`, and `"Name"` follow the +same rule. Use the source text directly when its meaning is shared; add context +only for a real ambiguity. + +## Keep Whole Messages Together + +Message length alone is not a reason to invent a semantic key. A sentence or +paragraph that forms one translatable unit may remain its own source-text key: + +```ts +translate( + "Deleting this project will permanently remove its settings and associated data.", +); +``` + +Never split natural language into separately translated fragments merely to +shorten a key. Translators must be able to reorder the whole message. + +Use a stable semantic id with an explicit source/default value only when the +content is genuinely managed as structured content rather than ordinary UI +copy, for example: + +- multi-paragraph help or onboarding content; +- localized Markdown or rich text; +- legal documents; +- large independently maintained content blocks. + +```ts +translate("project-deletion-explanation", { + defaultValue: projectDeletionExplanation, +}); +``` + +## Interpolation, Plurals, And Formatting + +Keep placeholders inside the complete source message and pass their values +through the translator: + +```ts +translate("Delete {{name}}?", { name }); +translate("Created by {{author}}", { author }); +``` + +Do not concatenate or template together fragments of natural language. A +rendered message containing a dynamic value may not exactly match its source +key, but its static wording remains searchable. + +### Plurals + +Use the localization engine's plural/select support for counts and grammatical +variants. Do not choose English singular/plural forms with component logic. +Follow the installed engine's resource format because plural categories differ +between locales. + +For example, i18next JSON v4 resolves plural variants from a base key and the +required `count` option: + +```ts +translate("{{count}} project", { count }); +``` + +```jsonc +// source locale +{ + "{{count}} project": "{{count}} projects", + "{{count}} project_zero": "No projects", + "{{count}} project_one": "{{count}} project", + "{{count}} project_other": "{{count}} projects" +} +``` + +```jsonc +// a locale with additional plural categories +{ + "{{count}} project": "{{count}} проєктів", + "{{count}} project_zero": "Немає проєктів", + "{{count}} project_one": "{{count}} проєкт", + "{{count}} project_few": "{{count}} проєкти", + "{{count}} project_many": "{{count}} проєктів", + "{{count}} project_other": "{{count}} проєкту" +} +``` + +The base entry keeps the ordinary typed lookup and non-contextual fallback +explicit. The engine selects `_zero`, `_one`, `_few`, `_many`, or `_other` +according to the active locale. A locale only defines the categories required +by the installed engine and its plural rules; do not copy English categories +blindly. + +For i18next, the option must be named `count`. Other engines may use another +resource shape or ICU message syntax. Reuse the project's installed plural +mechanism and verify its current official documentation. + +Context and plural variants may be combined. Pass both `count` and `context`; +the engine composes their resource suffixes. Never construct `_one`, `_other`, +or combined suffixes in application code. + +### Locale-Aware Formatting + +Format dates, times, numbers, percentages, units, and currencies with the +project's locale-aware formatter. Do not interpolate locale-insensitive +`toString()` output into a translated sentence. + +When the localization engine supports `Intl`-backed formatting, keep the +formatter inside the complete message. For i18next versions that support its +built-in formatters: + +```jsonc +{ + "Total: {{amount, currency(USD)}}": "Total: {{amount, currency(USD)}}", + "Updated on {{date, datetime}}": "Updated on {{date, datetime}}", + "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete": "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete" +} +``` + +```ts +translate("Total: {{amount, currency(USD)}}", { + amount: 1250, +}); + +translate("Updated on {{date, datetime}}", { + date: updatedAt, + formatParams: { + date: { + dateStyle: "medium", + timeStyle: "short", + }, + }, +}); + +translate( + "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete", + { + progress: 0.725, + }, +); +``` + +The active locale controls separators, currency presentation, ordering, and +date/time wording. Other locales keep the same placeholders but may move them +within the message. + +If the localization engine does not own formatting, format through the +project's locale-aware formatter and interpolate the result: + +```ts +translate("Total: {{amount}}", { + amount: formatCurrency(amount, { currency: "USD", locale: activeLocale }), +}); +``` + +## Use Stable Domain Keys For Enums And Machine Values + +Enums and other closed machine-defined sets already have stable identities. +They do not need source text as their lookup key. + +When a value is presented in multiple ownership areas, keep one exhaustive, +typed translator: + +```ts +const STATUS_KEYS = { + [Status.Active]: "enums.status.active", + [Status.Archived]: "enums.status.archived", +} satisfies Record<Status, TranslationKey>; + +export const translateStatus = (status: Status) => + translate(STATUS_KEYS[status]); +``` + +The resource values remain searchable: + +```json +{ + "enums.status.active": "Active", + "enums.status.archived": "Archived" +} +``` + +Apply this to stable statuses, roles, modes, categories, and similar constants. +The key must be mechanically derived from the domain value, not creatively +named after one component. + +Use a local source-text key when a one-off label only happens to resemble an +enum value. Do not route unrelated copy through a shared enum translator. + +## Derive Key Types From The Source Catalog + +The canonical source locale is the key authority. Derive key and language types +from real resources instead of maintaining manual unions: + +```ts +import source from "./locales/en.json"; + +export const resources = { + en: { translation: source }, + uk: { translation: uk }, +} as const; + +export type Language = keyof typeof resources; +export type TranslationKey = keyof typeof source; +``` + +For a flat source-text catalog, prefer the simple `keyof` type. Do not build a +recursive path utility that: + +- permits intermediate objects as translation results; +- generates both dot and bracket forms; +- duplicates the localization library's own key inference; +- slows TypeScript as the catalog grows. + +When the library supports resource-based type augmentation, connect it directly +to the source catalog. For i18next: + +```ts +declare module "i18next" { + interface CustomTypeOptions { + defaultNS: "translation"; + keySeparator: false; + nsSeparator: false; + returnObjects: false; + resources: { + translation: typeof source; + }; + } +} +``` + +Keep runtime configuration and type augmentation aligned. A flat natural-key +i18next catalog normally requires: + +```ts +i18n.init({ + resources, + keySeparator: false, + nsSeparator: false, +}); +``` + +Otherwise periods or colons inside sentences may be interpreted as key or +namespace separators. Follow the exact installed library version and official +documentation when configuring this foundation. + +If the project uses multiple catalogs or namespaces, split them for real +ownership, loading, or deployment reasons. Do not recreate page-tree +namespacing merely to organize keys visually. + +JSON resources provide useful key inference but may not preserve enough literal +information for fully typed interpolation variables. If stronger typing is +needed, prefer source resources defined with `as const` in TypeScript or +generated declarations. Never hand-maintain a second resource interface. + +## Keep Translator APIs Honest + +Prefer the localization library's typed translator directly. A project wrapper +may adapt ergonomics, but it must preserve: + +- the inferred key type; +- option and interpolation types; +- context and plural overloads; +- the real return type. + +Do not weaken the boundary with `any`, `as unknown as string`, or a general +`string` key. + +Do not custom-memoize translated results by only `key + options`. The active +locale, loaded resources, and runtime language changes also affect the result. +Use the localization engine's own resource behavior unless the project has a +proven locale-aware caching abstraction. + +Inside reactive UI, use the project's translation hook so the component +responds to locale changes. Outside React, use the established direct +translator. + +Do not eagerly translate module-level constants when the application can change +locale without reloading. Store keys and translate at the consumption boundary, +or construct locale-sensitive schemas/options through the project's established +flow. Module-level translation is acceptable only when the application +deliberately reloads on locale change or otherwise guarantees reevaluation. + +Do not pass a translator through component props when each component can obtain +the project translator from its normal context. + +## Validate Every Locale + +Typing call sites from the source locale proves that a used source key exists. +It does not automatically prove that every other locale has the same keys. + +Treat localization validation as layered. The foundation must detect missing, +extra, and orphaned keys; incompatible placeholders; incomplete plural/context +variants; invalid resource shapes; and stale keys after copy changes. + +No single successful command proves all of these properties. Combine the +repository's resource validator, exact parity or policy checks, source usage +analysis when it understands the project's translator API, and type-checking. + +When using or considering `@lingual/i18n-check`, read +[references/i18n-check.md](references/i18n-check.md) before trusting it. Its +resource checks are useful, but source parsing and i18next plurals have limits. + +Prefer automatic correction for deterministic ordering, but do not silently +fabricate translations. A source-language value copied into another locale must +remain visibly untranslated according to the project's workflow. + +Keep resource keys deterministically sorted when the project stores catalogs in +version control. + +## What Not To Translate + +- User-generated or backend-provided content. +- User names, record ids, filenames, and machine-readable codes. +- Proper-noun brands that intentionally remain identical across locales. +- Raw dynamic values that should be formatted rather than translated. + +Translate the surrounding static message as one unit. Do not assume +backend-provided labels are localized unless the API contract guarantees it. + +## Workflow + +1. Inspect the localization runtime, source locale, resources, typed translator, + context/plural conventions, and validation commands. +2. Find every affected user-facing string, including accessibility, + notification, and validation copy. +3. Search the exact source text before adding it. +4. Reuse an existing key when both source text and meaning match. +5. Add linguistic context when identical source text needs a different + translation. +6. Use a typed domain key only for an enum/machine value or a justified + structured-content exception. +7. Add the source entry and update other locales through the project's + translation workflow. +8. Replace the call site with the typed translator and keep interpolation or + plural logic inside the message. +9. Remove replaced or stale keys. +10. Run locale sorting/parity validation, formatting, linting, and type-checking. + +## Avoid + +- invented page/component/element keys for ordinary copy; +- namespaces derived from file-system position; +- duplicating identical messages per call site; +- sharing identical source text that needs different linguistic context; +- semantic ids for ordinary sentences merely because they are long; +- sentence fragments and translated-string concatenation; +- runtime-generated or untyped translation keys; +- manual key unions or duplicate resource interfaces; +- recursive path types for a flat source-text catalog; +- casts that hide object or missing-key results; +- custom translation memoization that ignores locale; +- assuming source-locale typing validates every locale. + +## Related Skills + +- Localized validation messages and form lifecycle → **forms**. +- User-facing component copy and locale-reactive rendering → **components**. +- Localized success, error, warning, notification, and recovery feedback → + **user-feedback**. +- Placement of localization modules and resource files → **file-structure**. diff --git a/.claude/skills/localization/references/i18n-check.md b/.claude/skills/localization/references/i18n-check.md new file mode 100644 index 0000000..8a2e157 --- /dev/null +++ b/.claude/skills/localization/references/i18n-check.md @@ -0,0 +1,569 @@ +# i18n-check Integration Reference + +Use this reference when a project already uses `@lingual/i18n-check`, when +introducing it, or when deciding whether its output is sufficient for a +localization change. + +## Contents + +- [Role And Boundaries](#role-and-boundaries) +- [Supported Checks](#supported-checks) +- [Recommended Commands](#recommended-commands) +- [Required Validation Layers](#required-validation-layers) +- [Day-To-Day Workflows](#day-to-day-workflows) +- [Source Usage Compatibility](#source-usage-compatibility) +- [Source-Text Key Edge Cases](#source-text-key-edge-cases) +- [Interpolation And Rich Messages](#interpolation-and-rich-messages) +- [Plurals And Context](#plurals-and-context) +- [Catalog And File Edge Cases](#catalog-and-file-edge-cases) +- [Ignore, Exclude, And Reporting](#ignore-exclude-and-reporting) +- [Version-Specific Caveats](#version-specific-caveats) +- [Integration Checklist](#integration-checklist) + +## Role And Boundaries + +Treat `i18n-check` as a resource compatibility checker, not as the complete +localization authority. + +It is effective at comparing a canonical source locale with target locale files +and returning a non-zero exit code for detected problems. This makes it useful +for local validation, pre-commit hooks, and CI. + +It does not translate copy, migrate renamed keys, judge translation quality, or +prove that every runtime localization path works. A successful run only proves +the checks that were actually enabled and supported by the installed version. + +Inspect all of the following before configuring it: + +1. The installed `@lingual/i18n-check` version. +2. The localization engine and message format. +3. The source locale and resource layout. +4. The project's translator functions, hooks, and rich-text components. +5. The runtime key, namespace, plural, and context separators. +6. Existing type-check, lint, extraction, and CI commands. + +Consult the installed package and current official documentation instead of +assuming that behavior described for another version still applies: + +- <https://lingual.dev/i18n-check/> +- <https://github.com/lingualdev/i18n-check> + +## Supported Checks + +The CLI exposes four checks: + +| Check | Intended guarantee | +| --- | --- | +| `missingKeys` | A source key exists in each matched target resource. | +| `invalidKeys` | Source and target messages preserve compatible message elements. | +| `unused` | A source-locale key was not found in parsed application source. | +| `undefined` | A parsed application key does not exist in the source locale. | + +By default, use `missingKeys` and `invalidKeys` as the dependable resource +validation layer. `unused` and `undefined` require `--unused`/`-u` and are only +reliable when the parser recognizes the project's actual call-site syntax. + +For i18next messages, `invalidKeys` can detect common structural changes such +as: + +- a missing, added, or renamed interpolation variable; +- escaped versus unescaped interpolation; +- changed i18next nesting expressions; +- missing or changed rich-text tags; +- changed interval-plural expressions supported by the parser. + +It deliberately ignores ordinary source and target text differences. It cannot +determine whether a translation is accurate, grammatical, current, or +appropriate for its context. + +The CLI can load one or multiple locale folders and supports common layouts +such as one file per locale, one folder per locale, and matching multiple files +inside each locale folder. Discovery and matching do not prove that an expected +locale or file exists; validate that separately. + +Format behavior is version-specific. Inspect support for ICU, i18next, +react-intl, and next-intl in the installed package instead of assuming that +every check works equally for every format. + +## Recommended Commands + +Make the resource contract explicit: + +```json +{ + "scripts": { + "validate:i18n:resources": "i18n-check -l src/localization/locales -s en -f i18next -o missingKeys invalidKeys" + } +} +``` + +Adjust paths, source locale, and format to the inspected project. Supported +resource inputs are JSON and YAML. + +Run the resource check: + +- after adding, removing, or changing localized copy; +- in the normal local validation workflow; +- in a pre-commit hook when it remains fast; +- in CI as the authoritative blocking gate. + +Pre-commit alone is insufficient because it can be bypassed. + +Only add source usage validation after a compatibility test: + +```json +{ + "scripts": { + "validate:i18n:usage": "i18n-check -l src/localization/locales -s en -f i18next -u src -o unused undefined" + } +} +``` + +Do not merge this command into a blocking workflow merely because it runs. First +confirm that it finds representative calls through every project translator, +hook, component wrapper, namespace, context, and dynamic-key registry. + +Prefer the standard reporter for actionable local and CI output. Use the +summary reporter only when exact affected keys are available through another +artifact. + +When the CLI cannot represent the project's resource ownership, use the +package's documented public check functions to compose project tooling. Verify +the installed exports and keep project-specific policy checks outside the +package. Do not depend on private `dist/` modules. + +## Required Validation Layers + +Use `i18n-check` as one part of this stack: + +| Layer | Responsibility | +| --- | --- | +| Resource validation | Missing source keys and structurally incompatible messages. | +| Project policy/parity validation | Expected locales/files, reverse base-key parity, plural/context completeness, and source-text policy. | +| Type-checking | Valid call-site keys, options, interpolation values, context, and count types. | +| Source usage analysis | Undefined and orphaned keys when the parser understands the project API. | +| Runtime or integration tests | Locale switching, fallback behavior, lazy resources, rich messages, and formatting. | +| Human review | Meaning, grammar, tone, cultural fit, and product terminology. | + +The project-specific policy validator should cover gaps that `i18n-check` does +not reliably cover: + +- an explicit manifest of supported locales and required resource files; +- bidirectional parity for ordinary keys and context identities after grouping + locale-specific plural variants; +- target-only and orphaned keys; +- duplicate resource keys; +- source-text key conventions and justified semantic-key exceptions; +- required plural categories for each locale; +- combined context and plural variants; +- placeholder compatibility in target-only plural categories; +- deterministic resource ordering when required. + +Do not weaken these guarantees to fit one tool. Extend the validation stack. + +## Day-To-Day Workflows + +### Add Copy + +1. Search for an existing source message with the same meaning. +2. Add the source entry. +3. Add or explicitly queue every target translation through the established + workflow. +4. Update the typed call site. +5. Run resource validation, project parity validation, and type-checking. + +`missingKeys` should identify a target locale that did not receive the new +source key. It cannot create the translation. + +### Change Source-Text Copy + +A wording change is a key migration: + +```ts +translate("Delete project?"); +translate("Delete this project?"); +``` + +Perform it atomically: + +1. Find every call site of the old exact message. +2. Add the new source key. +3. Carry each existing translation to the new key and review it against the new + meaning. +4. Update all call sites. +5. Remove the obsolete key from every locale. +6. Run resource validation, reverse parity, source usage validation when + compatible, and type-checking. + +`missingKeys` will report the new key when targets still contain only the old +key. It will not identify the change as a rename, migrate translations, or +report the old target-only key. + +Capitalization, punctuation, and meaningful whitespace are part of a +source-text key. Even a small wording edit creates a distinct key. + +### Change Interpolation + +When changing: + +```text +Hello {{name}} +Hello {{user}} +``` + +update the source message, every target message, the call-site option, and its +type. `invalidKeys` can catch a target that retained `{{name}}`; it does not +prove that the call site supplies `user`. + +### Remove A Feature + +Remove its call sites and owned source keys, then remove the same keys from +every target locale. + +The basic resource check will not report target-only leftovers. Use reverse +parity and, when compatible, `unused`. + +### Add A Locale Or Resource File + +Register the locale in the project's explicit locale manifest, create every +required resource file, then run validation. + +Do not depend on directory discovery to prove completeness. A checker cannot +report a locale or file that it was never told must exist. + +## Source Usage Compatibility + +The i18next source parser commonly recognizes direct forms such as: + +```ts +t("Save"); +i18n.t("Save"); +const { t } = useTranslation(); +t("Save"); +``` + +It also recognizes `Trans` and can be given additional component wrapper names +through `--parser-component-functions`. + +Do not assume that it recognizes project wrappers: + +```ts +translate("Save"); + +const tx = useTranslate(); +tx("Save"); +``` + +The CLI option for additional component functions extends `Trans` component +recognition; it does not necessarily configure arbitrary translation function +or hook names. Verify the installed version before relying on wrapper support. + +Do not rename a clear project API merely to satisfy a static parser. Prefer a +thin project-aware source checker or contribute configurable function support +upstream. + +Avoid importing private `dist/` parser modules into permanent project tooling. +Internal package paths and options are not a stable public contract. + +### Dynamic Keys + +Static analysis cannot generally resolve: + +```ts +t(`status.${status}`); +t(prefix + id); +translate(KEY_FROM_RUNTIME_DATA); +``` + +Prefer explicit typed registries for closed machine-defined values: + +```ts +const STATUS_KEYS = { + active: "enums.status.active", + archived: "enums.status.archived", +} as const; +``` + +Validate the registry exhaustively with TypeScript and include it in the +project's localization policy check. Do not scatter ignores for dynamic keys. + +### False Usage + +Inspect whether the selected source paths include: + +- tests and fixtures; +- stories and examples; +- generated code; +- comments containing `t(...)`; +- dead or unreachable modules. + +These can keep a production key classified as used. Some parser versions scan +translation-looking calls in comments. + +An unrelated application function named `t` can create the opposite problem: +the parser may treat its string argument as a translation key. + +Object-returning calls may also cause a whole subtree to be skipped: + +```ts +t("countries", { returnObjects: true }); +``` + +Treat an unused report as static evidence that still requires ownership-aware +review. + +### Namespaces + +Test multiple namespaces explicitly. Some versions treat a key as used without +fully proving that it was used through the correct namespace. + +Source-text keys containing `:` need particular care. A source parser may split: + +```ts +t("Error: invalid value"); +``` + +as namespace `Error` plus key ` invalid value`, even when runtime i18next uses +`nsSeparator: false` or natural-key detection. + +Resource-only checks remain useful, but `unused` and `undefined` are unreliable +until this case passes a project smoke test. + +## Source-Text Key Edge Cases + +`i18n-check` compares resource keys; it does not enforce the project's key +policy. + +Add project validation for these invariants: + +- ordinary source-text keys follow the canonical source wording; +- the canonical source value has not silently drifted from its key; +- context variants intentionally differ from their base key; +- semantic keys are limited to enums, machine values, or documented structured + content; +- a flat literal key cannot collide with an equivalent nested resource path. + +Do not apply a universal `key === value` rule blindly. Context variants and +justified semantic keys legitimately differ: + +```json +{ + "Open": "Open", + "Open_action": "Open", + "enums.status.active": "Active" +} +``` + +Literal periods and colons must agree with runtime `keySeparator` and +`nsSeparator` behavior. The resource checker does not prove runtime lookup. + +Treat plural and context suffixes as reserved according to the installed +localization engine. A normal semantic key that accidentally ends in `_one` or +`_other` may be normalized as a plural form by tooling. + +## Interpolation And Rich Messages + +Use `invalidKeys` to protect the structural contract between source and target +messages, then keep call-site correctness in the type system. + +Validate representative cases: + +- reordered placeholders remain valid; +- renamed or missing placeholders fail; +- escaped and unescaped interpolation cannot be mixed accidentally; +- formatting directives preserve their arguments; +- nested translation expressions reference real keys; +- rich-text tags preserve the elements required by the renderer. + +The checker may compare a set of message elements without proving their exact +semantic nesting, rendering behavior, or component binding. + +It also does not validate: + +- that a custom formatter is registered; +- that date, time, number, currency, or unit inputs have the right runtime type; +- that the active locale reaches the formatter; +- that a nested `$t(...)` reference resolves; +- that translated prose retained the intended meaning. + +Test those boundaries through types and focused runtime tests. + +## Plurals And Context + +i18next plural categories vary by locale and are based on `Intl.PluralRules`. +The `count` option is required for plural selection. Context and plural suffixes +can be combined. + +Consult the installed engine's current documentation: + +- <https://www.i18next.com/translation-function/plurals> +- <https://www.i18next.com/translation-function/context> + +Do not treat a successful i18next `missingKeys` check as proof of plural +completeness. Some `i18n-check` versions normalize plural suffixes to a base key. +Consequently, one existing target variant may satisfy the check even when other +required categories are absent. + +For example, this target may pass base-key presence despite being incomplete: + +```json +{ + "project_one": "{{count}} project" +} +``` + +Locale-specific categories can also escape structural comparison when the +source locale does not define the same category. A broken target `_few` or +`_many` message may therefore remain undetected. + +The project policy validator must: + +1. Determine cardinal categories for each supported locale. +2. Validate ordinal categories separately when used. +3. Treat `_zero` as an explicit optional override unless product requirements + make it mandatory. +4. Require the correct variants for every pluralized base key. +5. Repeat the check for every context variant. +6. Validate interpolation elements across all target-only categories. +7. Respect the installed engine's configured plural and context separators. + +Do not copy English plural categories into every locale merely to satisfy a +checker. + +## Catalog And File Edge Cases + +### Reverse Parity + +The normal comparison direction is source to target. A target-only key can pass +`missingKeys` and `invalidKeys`. + +Run a locale-aware reverse comparison or bidirectional parity check to find: + +- old target keys left after a source-text rename; +- keys removed from the source but not from targets; +- accidental target-only additions. + +Normalize recognized plural-category suffixes before deciding that a target key +is extra. Preserve context identity: a target-only context remains suspicious, +while a target locale may legitimately require `_few` or `_many` when the +source locale does not. + +### Missing Files And Locales + +Directory discovery only compares files it finds and can match. It does not +prove that a deleted locale or resource file was expected. + +Maintain an explicit locale/resource manifest and verify it before content +comparison. + +### Empty And Null Values + +Verify installed-version behavior for empty strings, `null`, `false`, and +numeric values. Some implementations use truthiness for missing checks, making +an intentionally empty string appear missing. + +Prefer string-valued leaf messages unless the established engine explicitly +supports another resource shape. + +### Duplicate Keys + +Standard `JSON.parse` accepts duplicate object keys and silently keeps the last +value. A checker that reads resources through `JSON.parse` cannot report the +overwritten definition. + +Use a duplicate-aware JSON parser, formatter, or lint rule before running +resource comparison. YAML parser behavior must also be verified rather than +assumed. + +### Nested And Flat Resources + +Many checkers flatten nested objects into dotted paths. Avoid mixing: + +```json +{ + "a.b": "flat", + "a": { + "b": "nested" + } +} +``` + +This can produce a collision after flattening even when the raw JSON keys are +different. + +### Unsupported Sources + +The CLI resource loader supports JSON and YAML. TypeScript resource objects, +remote catalogs, generated bundles, or custom formats require a generation +step or public API integration. + +Source parsing commonly covers `js`, `jsx`, `ts`, and `tsx`. Verify other +extensions such as `mjs`, `mts`, MDX, Vue, or Svelte before enabling blocking +usage checks. + +## Ignore, Exclude, And Reporting + +Use `--ignore` and `--exclude` only for an explicit, reviewed exception. + +Each exception must identify: + +- the exact key, locale, file, or ownership boundary; +- why the normal invariant does not apply; +- whether the exception is temporary; +- how removal will be detected. + +Avoid broad wildcard ignores. Verify their matching semantics in the installed +version; some versions use substring matching for wildcard prefixes and can +silence unrelated keys. + +Do not exclude an entire locale merely because it is incomplete unless product +policy explicitly allows that locale to ship incomplete. + +Use the standard reporter when developers must fix individual keys. A summary +is useful for metrics, but counts alone are insufficient remediation output. + +## Version-Specific Caveats + +The following behaviors were observed in `@lingual/i18n-check@0.9.5`. Re-test +them when the installed version differs: + +- the i18next source parser defaults to `t`, `useTranslation`, + `withTranslation`, and `Trans`; +- custom `translate` and custom translation hooks are not configurable through + the documented CLI; +- parsed keys containing `:` are split as namespace-prefixed keys; +- context usage normalization assumes `_`; +- plural suffixes are collapsed during missing and usage checks; +- namespace-aware usage matching is incomplete; +- `returnObjects: true` creates skippable key subtrees; +- source-file `--exclude` behavior does not necessarily match locale-file + exclusion behavior; +- wildcard ignores use broad substring matching; +- missing checks treat falsy target values as absent; +- interpolation prefix/suffix comparison contains an implementation defect. + +Do not preserve these limitations as timeless rules. They describe why the +installed tool must be tested against the real project instead of trusted by +name. + +## Integration Checklist + +Before declaring localization validation complete: + +- [ ] Identify the canonical source locale. +- [ ] Verify every expected locale and resource file through a manifest. +- [ ] Run `missingKeys` and `invalidKeys`. +- [ ] Run locale-aware reverse or bidirectional base-key parity. +- [ ] Detect duplicate resource keys before parsing destroys that evidence. +- [ ] Validate source-text, context, and semantic-key conventions. +- [ ] Validate cardinal and ordinal plural categories per locale. +- [ ] Validate combined context/plural variants. +- [ ] Confirm placeholder compatibility in every locale-specific variant. +- [ ] Type-check keys, translator options, interpolation values, and `count`. +- [ ] Prove the source parser recognizes every project translator API before + enabling `unused` or `undefined`. +- [ ] Test natural keys containing punctuation, especially `:` and `.`. +- [ ] Review every ignore or exclude exception. +- [ ] Run focused runtime tests for locale switching, fallback, lazy resources, + formatting, and rich messages. +- [ ] Run the blocking validation in CI. diff --git a/.claude/skills/logging/SKILL.md b/.claude/skills/logging/SKILL.md new file mode 100644 index 0000000..eabedde --- /dev/null +++ b/.claude/skills/logging/SKILL.md @@ -0,0 +1,327 @@ +--- +name: logging +description: Use whenever work adds, changes, reviews, debugs, or consumes application logging in web, React Native, Electron, Electrobun, or another client runtime. Trigger for logger calls, structured log or event records, child loggers, scopes and context, log levels, console usage, transports, browser or native persistence, Electron renderer-to-main logging, logging RPC, batching, buffering, flushing, file logs, logger call sites, origin stacks, source maps, symbolication, Sentry or OpenReplay integration, telemetry breadcrumbs, tracked application events, or deciding where logging infrastructure belongs, even if the request only mentions diagnostics, traces, persisted logs, analytics events, or replacing console calls. +--- + +# Logging + +Use one structured logging contract and facade across client runtimes. Keep +platform delivery behind transports so application code does not know whether a +record goes to a console, browser storage, a native file, or another process. + +Logging records diagnostic facts. It does not decide how an error is handled, +whether a user sees feedback, whether an operation retries, or whether an +exception becomes a tracked incident. + +Keep the failure stack and the logging origin distinct. An error stack answers +where the failure was created or thrown. An origin stack answers where a log or +asynchronous operation was initiated. Preserve both without rewriting either. + +Explicit application events may use the same facade and transport pipeline when +the record keeps its event identity. Do not infer analytics events by parsing +ordinary human-readable log messages. + +## Inspect Before Extending + +Before changing logging: + +1. Find the public logger facade and its factory. +2. Find where the root logger is configured for each runtime. +3. Find registered transports and their lifecycle. +4. For desktop applications, trace renderer-to-host delivery and identify which + runtime owns file access. +5. Check whether the repository already has log persistence, retention, + telemetry breadcrumbs, or an established privacy policy. +6. Check how development and production stacks are source-mapped or + symbolicated for every runtime and release channel. + +Extend the existing foundation when it preserves the boundaries below. Do not +create a second application logger for one feature or runtime. + +## Keep One Contract, Not One Runtime Instance + +Main, renderer, preload, workers, browser pages, and React Native JavaScript run +in separate environments. They cannot share one physical singleton. + +Provide: + +- one platform-neutral logger contract; +- one logger factory and facade behavior; +- one process-local root logger per runtime; +- platform transports selected at the runtime's composition root. + +Place that contract at the narrowest real common owner. A monorepo with several +runtimes may justify a package; a single React Native application may only need +`core/logging`. Do not manufacture a package boundary that has no consumers. + +Feature code imports only its configured logger facade. It must not import file +writers, RPC clients, telemetry SDKs, or storage adapters merely to emit a log. + +## Use A Direct Record Flow + +Keep the core pipeline small: + +```text +logger method + -> merge child scope + -> create one structured log or event record + -> fan out to configured transports +``` + +Do not insert generic normalization, conversion, sanitization, enrichment, or +middleware chains. A transport may perform the smallest representation change +required by its own boundary. Keep that change inside the transport. + +Read `references/facade-and-records.md` when defining or changing the logger +contract, record shape, child behavior, messages, levels, or error argument. + +## Separate Stable Scope From Event Context + +Use `child(...)` for values repeated by several records: + +```ts +const log = logger.child({ + module: "authentication", +}) + +const refreshLog = log.child({ + operation: "refresh-session", + requestId, +}) +``` + +Pass values belonging to one event to the log method: + +```ts +refreshLog.info("Session refreshed", { + userId, + expiresAt, +}) +``` + +When the facade supports explicit tracked events, use the dedicated method: + +```ts +const mtlsLog = logger.child({ module: "mtls" }) + +mtlsLog.event("mtls_provision", { + method: "jwt", +}) +``` + +The event name is a stable machine contract. It is not an ordinary `info` +message reinterpreted later by a telemetry transport. + +Keep scopes small and mostly flat. Prefer identifiers and operational metadata +over entire entities or live runtime objects. + +Snapshot the scope and event context shallowly when creating a logger or record +so later caller mutation cannot change a queued record. Do not deep-clone or +walk values; callers should provide small, boundary-compatible fields. + +Use stable human-readable messages and put variable values in context: + +```ts +// Avoid +log.info(`Project ${projectId} opened`) + +// Prefer +log.info("Project opened", { projectId }) +``` + +## Choose Levels By Meaning + +- `debug`: detailed diagnosis that may be disabled or dropped. +- `info`: a meaningful lifecycle event, state transition, or completed action. +- `warn`: an unexpected but recoverable state or an applied fallback. +- `error`: a failed operation or diagnostic error event. + +Do not log every function call or every successful request. Logging volume must +remain useful enough to inspect. + +Let each transport choose its own level threshold. Feature code must not know +whether a particular environment persists `debug`, `info`, or only higher +levels. + +An explicit application event is a separate record kind, even when its local +console or file representation uses the `info` level. + +## Preserve Diagnostic Origins + +Do not mistake the logger implementation frame for the real call site. +Capturing a stack inside a transport is too late: it points to the transport, +queue flush, RPC handler, or file writer. + +- Keep an original `Error` value unchanged in the local record. +- Capture an optional origin stack synchronously at the public logger call or + before scheduling work that will finish across an async boundary. +- Perform automatic capture in the public method itself. Capturing in a shared + private emitter or origin resolver leaves that helper as the leading frame. +- Store the origin separately, for example as `originStack`; never append it to + `error.stack`. +- Let infrastructure observers explicitly suppress automatic capture when they + have no earlier origin. A Query cache callback, global error listener, RPC + handler, and file writer must not label their observation stack as the + operation origin. +- Preserve a received renderer or worker origin when a host persists the + record. Do not replace it with the host ingestion stack. +- Do not remove frames by a fixed `split(...).slice(n)` rule. Stack formats and + wrapper depth differ between V8, JavaScriptCore, and Hermes. +- Do not capture a stack for every production `debug` or `info` record without + measuring the cost. Configure a deliberate policy, commonly all enabled + levels in development and `warn`/`error` or explicitly traced operations in + production. + +Read `references/trace-origins.md` whenever work touches stack traces, logger +call sites, async origins, source maps, symbolication, Query or Mutation +diagnostics, or cross-runtime log delivery. + +## Compose Platform Transports At Bootstrap + +The logger facade should exist before optional platform services initialize. +Keep a console transport available as the early and emergency fallback, then +register persistence, RPC, or telemetry transports at bootstrap. + +- Web may use console, browser persistence, and remote transports. +- React Native may use console, native persistence adapters, and telemetry. +- Electron or Electrobun main may use console and native file transports. +- Electron or Electrobun renderer may use console and a buffered RPC transport + that delivers records to the host-owned file transport. +- Tests may use an in-memory or no-op transport. + +Provider-specific telemetry transports belong to the provider integration +module. For example, an OpenReplay renderer package may expose a transport that +maps explicit event records to `trackEvent`, while application features remain +unaware of OpenReplay. If the repository deliberately defines every `error` +record as incident-worthy, the provider transport may also map those records to +`captureException`. That choice makes `logger.error` part of the incident +contract: do not keep a second reporting facade that captures the same failure. +If diagnostic errors and incidents differ, represent that distinction +explicitly instead of guessing from a message. + +Read `references/platform-transports.md` when adding transports, file +persistence, Electron renderer-to-main delivery, batching, flushing, or +transport lifecycle. + +Read `references/proven-platform-patterns.md` for compact web, React Native, +Electron, and Electrobun implementation shapes and the edge cases they expose. + +## Keep Transports Isolated + +A transport must never break application behavior or prevent another transport +from receiving a record. + +- Keep logger methods synchronous from the caller's perspective. +- Put queues and batching inside asynchronous transports, not the core logger. +- Bound every queue. +- Make registration idempotent and removable for tests, HMR, and teardown. +- Support best-effort `flush()` and `dispose()` where a transport needs them. +- Report a broken transport directly through a guarded console fallback, not + through the same logger. + +Do not hide an unbounded pre-initialization queue in the logger. Early records +may go only to the console until optional transports are ready. + +## Keep Platform Boundaries Honest + +In a desktop renderer, file access belongs to the privileged host. Deliver +structured records through the existing native-RPC foundation instead of +granting renderer code filesystem access. + +Application code still calls the common facade. The RPC client is an +implementation detail of the renderer transport, not the public logger used by +features. + +Prefer one batch ingestion method over duplicating `debug`, `info`, `warn`, and +`error` as RPC methods. The host must persist received renderer records without +re-logging them through its own root logger; re-logging changes source metadata, +timestamps, and can create loops or duplicates. + +Register logging RPC in the native handler registry, but do not expose it as an +agent capability merely because the repository uses the same registry for both. +Operational transport methods and user- or agent-invokable capabilities have +different semantics. + +## Do Not Build An Error Conversion System + +The logger may accept an `unknown` error value for local diagnosis. The logging +core must not inspect error classes, extract domain fields, traverse custom +causes, or maintain error-type registries. + +Local transports may use the original value. A transport that requires a wire +or persistent representation owns a small, explicitly lossy representation. For +an actual `Error`, it may preserve the standard text and stack directly. For +another value, use a small fallback such as `JSON.stringify`, with a final +string fallback if encoding throws. Keep a separately captured `originStack` +separate on the wire. Do not inspect domain fields or reconstruct an error +instance on the receiving side. + +If a known diagnostic value matters, the caller that knows its meaning should +pass it explicitly in context. Adding a new application error type must not +require editing logging infrastructure. + +## Do Not Promise Automatic Secret Cleanup + +Do not pass passwords, tokens, cookies, private keys, raw authorization +payloads, or unnecessary personal data to the logger. + +Do not invent a recursive sanitizer and rely on it to make unsafe logging safe. +If a repository or telemetry SDK already provides a proven boundary safeguard, +preserve it as defense in depth without turning it into a general application +error-conversion pipeline. + +## Keep Observability Concerns Distinct + +- Sentry-style breadcrumbs may be implemented as a logging transport. +- Decide whether `logger.error` means a diagnostic error or an incident-worthy + error. Do not automatically capture every error unless the repository makes + that contract explicit. +- When an incident provider is a logger transport, emit one eligible record and + let normal fan-out reach local persistence and the provider. Do not call the + provider separately from the same `reportError` flow. +- Stable typed application events may travel through the logging pipeline when + the facade and record distinguish them from ordinary logs. +- Metrics, timings, and analytics events must not be derived from human log + messages or an `info` level alone. +- Provider session control such as initialization, user identity, consent, and + reset is not a log transport and remains in the provider integration. +- User feedback is UI behavior, not a logging transport. + +## Verify The Result + +Before finishing logging work, verify that: + +- feature code imports only the configured logger facade; +- child scope and event context remain distinct; +- explicit application events remain distinguishable from ordinary logs; +- queued records cannot change when the caller later mutates its scope or + context object; +- messages are stable and dynamic values are structured; +- transports are registered once and fail independently; +- asynchronous transports have bounded queues and a flush policy; +- browser or Storybook execution does not instantiate a native transport when + its bridge is absent; +- renderer persistence crosses the established native boundary; +- the host preserves the renderer record rather than re-logging it; +- an error stack and a separately captured origin stack remain distinct; +- origin capture happens before async, queue, worker, or RPC boundaries; +- persisted and remote production stacks are symbolicated against artifacts + from the exact application release or update; +- existing persisted-log schemas remain readable or have an explicit migration; +- infrastructure observers can suppress misleading automatic origin capture; +- transport failures cannot recurse through the logger; +- no new normalization, sanitizer, or error-type registry was introduced; +- sensitive values are absent from records and transport payloads. + +## Related Skills + +- Native RPC contracts, handlers, renderer clients, and host registration -> + **native-integration**. +- Placement of the logging package, platform entrypoints, and local helpers -> + **file-structure**. +- Query and mutation ownership remains in **api-integration**; logging a request + does not move cache or error-handling responsibilities into this skill. +- Catch boundaries, reporting policy, retries, cancellation, and typed error + outcomes → **error-handling**. +- Error, success, warning, fallback, and recovery presentation → + **user-feedback**. diff --git a/.claude/skills/logging/agents/openai.yaml b/.claude/skills/logging/agents/openai.yaml new file mode 100644 index 0000000..53896fb --- /dev/null +++ b/.claude/skills/logging/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Logging" + short_description: "Structured cross-platform application logging" + default_prompt: "Use $logging to design or update structured logging across web, React Native, Electron renderer, and Electron main runtimes." diff --git a/.claude/skills/logging/references/facade-and-records.md b/.claude/skills/logging/references/facade-and-records.md new file mode 100644 index 0000000..baf2479 --- /dev/null +++ b/.claude/skills/logging/references/facade-and-records.md @@ -0,0 +1,368 @@ +# Logger Facade And Records + +Use this reference when defining or changing the public logger, record shape, +child behavior, message conventions, levels, or error argument. + +## Contents + +- [Minimal Contract](#minimal-contract) +- [Root And Child Loggers](#root-and-child-loggers) +- [Scope And Context](#scope-and-context) +- [Stable Messages](#stable-messages) +- [Explicit Application Events](#explicit-application-events) +- [Level Semantics](#level-semantics) +- [Error Values](#error-values) +- [Diagnostic Origins](#diagnostic-origins) +- [Transport Dispatch](#transport-dispatch) + +## Minimal Contract + +Adapt names to the repository, but preserve the separation between stable scope +and event-specific context: + +```ts +type LogLevel = "debug" | "info" | "warn" | "error" +type LogKind = "log" | "event" + +type LogScope = Record<string, unknown> +type LogContext = Record<string, unknown> + +interface LogRecord { + timestamp: number + kind: LogKind + level: LogLevel + message: string + scope: LogScope + context?: LogContext + error?: unknown + originStack?: string +} + +interface Logger { + debug(message: string, context?: LogContext): void + info(message: string, context?: LogContext): void + warn(message: string, context?: LogContext): void + error(message: string, error?: unknown, context?: LogContext): void + event(name: string, context?: LogContext): void + child(scope: LogScope): Logger +} +``` + +This is illustrative rather than a mandatory literal type. Reuse an established +contract when it represents the same behavior. + +## Root And Child Loggers + +Create one root logger for each runtime: + +```ts +const logging = createLogging({ + scope: { + runtime: "electron-renderer", + applicationVersion, + sessionId, + }, + transports: [consoleTransport], +}) + +export const logger = logging.logger +``` + +Runtime metadata belongs to the root configuration. Feature code should not +repeat it. + +A child creates another immutable facade with merged scope: + +```ts +const moduleLog = logger.child({ + module: "projects", +}) + +const operationLog = moduleLog.child({ + operation: "open-project", + requestId, +}) +``` + +Creating a child must not create another transport registry, queue, timer, or +file handle. All descendants dispatch through the same runtime logging +instance. + +Snapshot the supplied root and child scope objects shallowly. Otherwise a +caller can mutate an object after creating the logger and silently rewrite the +scope observed by a delayed transport: + +```ts +const scope = { module: "projects" } +const log = logger.child(scope) + +scope.module = "unrelated" // Must not alter `log`. +``` + +Use a child when several records share the value: + +```ts +const syncLog = logger.child({ + module: "sync", + operationId, +}) + +syncLog.info("Synchronization started") +syncLog.debug("Synchronization batch received", { itemCount }) +syncLog.info("Synchronization completed", { durationMs }) +``` + +Do not create a child merely to emit one line: + +```ts +logger.info("Project opened", { projectId }) +``` + +## Scope And Context + +Typical scope fields include: + +- runtime; +- module or capability; +- component or process; +- operation; +- request, correlation, session, or task identifier. + +Keep scope values small and suitable for transport. Do not attach service +clients, stores, React values, request objects, whole entities, or arbitrary +class instances. + +Context belongs to one record: + +```ts +log.info("Workspace loaded", { + workspaceId, + projectCount, + durationMs, +}) +``` + +The logging core should merge scope and preserve context without walking, +normalizing, or enriching their values. Take a shallow snapshot of event +context when creating the record because an asynchronous transport may flush +later. This only protects the top-level record fields; do not deep-clone nested +objects. + +## Stable Messages + +Keep messages readable and stable: + +```ts +// Avoid: every id produces a different message. +log.info(`Workspace ${workspaceId} loaded in ${durationMs}ms`) + +// Prefer: the message groups naturally; values remain queryable. +log.info("Workspace loaded", { + workspaceId, + durationMs, +}) +``` + +Do not require a second machine event name for every log. Metrics and analytics +events must not be inferred by parsing log messages. + +## Explicit Application Events + +When an application sends stable domain events to OpenReplay or another +telemetry provider, the configured logger may expose a distinct `event` +operation: + +```ts +const log = logger.child({ module: "mtls" }) + +log.event("mtls_provision", { + method: "jwt", +}) +``` + +The resulting record must retain `kind: "event"` across asynchronous queues and +renderer-to-host boundaries. Console and file transports may store it alongside +ordinary logs. A telemetry transport can forward it without guessing from the +message or level. + +Do not forward every `info` record as analytics. That silently changes +diagnostic text into a remote data contract, increases telemetry volume, and +makes harmless message edits alter dashboards. + +## Level Semantics + +### Debug + +Use for high-volume details needed while investigating behavior: + +```ts +log.debug("Request batch scheduled", { + requestCount, + delayMs, +}) +``` + +Debug records may be disabled, sampled, or dropped by a transport. + +### Info + +Use for meaningful lifecycle and business-operation milestones: + +```ts +log.info("Workspace opened", { workspaceId }) +``` + +Do not log every render, selector, helper call, or successful network request. + +### Warn + +Use when the application continued despite unexpected or degraded behavior: + +```ts +log.warn("Cached configuration unavailable; defaults applied", { + configurationId, +}) +``` + +A warning should communicate what degraded or what fallback was selected. + +### Error + +Use for a failed operation or diagnostic error event: + +```ts +log.error("Workspace synchronization failed", error, { + workspaceId, + operationId, +}) +``` + +This call records a failure. It does not mean the failure was handled, shown to +the user, retried, or reported as an incident. + +## Error Values + +Keep the public error parameter `unknown`. Do not require errors to inherit from +one application base class. + +The core logger passes the value to local transports unchanged. If a transport +must encode it, keep the fallback local and deliberately small: + +```ts +function stringifyError(error: unknown): string | undefined { + if (error === undefined) { + return undefined + } + + try { + const serialized = JSON.stringify(error) + return serialized && serialized !== "{}" ? serialized : String(error) + } catch { + try { + return String(error) + } catch { + return "[unserializable error]" + } + } +} +``` + +`JSON.stringify(new Error("failed"))` commonly produces `{}` because standard +error fields are not enumerable. Falling back to `String(error)` in that case +preserves the basic message without introducing a universal serializer, +middleware chain, or error registry. + +When a field is important and the caller knows its semantics, provide it +explicitly: + +```ts +log.error("Request failed", error, { + requestId, + status, +}) +``` + +Do not teach the logger to discover `status`, `details`, response bodies, or +domain-specific properties from arbitrary errors. + +## Diagnostic Origins + +An optional `originStack` belongs to the record itself, not ordinary event +context. It describes where the facade was called or where later asynchronous +work was initiated. + +Capture it in the public method before dispatch: + +```ts +function error( + message: string, + error?: unknown, + context?: LogContext, + options?: LogCallOptions, +): void { + const captureOrigin = + options?.captureOrigin ?? captureOriginForConfiguredPolicy("error") + const originStack = + options?.originStack ?? + (captureOrigin ? captureTraceOrigin().stack : undefined) + + dispatch({ + timestamp: Date.now(), + kind: "log", + level: "error", + message, + scope, + ...(context === undefined ? {} : { context: { ...context } }), + ...(error === undefined ? {} : { error }), + ...(originStack === undefined ? {} : { originStack }), + }) +} +``` + +Do not capture inside `dispatch()` or `transport.write()`: the first meaningful +frame will already be logging infrastructure. Do not concatenate the result +with `error.stack`. + +Do not hide the capture in a generic private emitter or origin resolver either: +that helper becomes the leading frame. An observer with no real operation +origin passes `{ captureOrigin: false }`; ordinary feature calls rely on the +configured runtime policy. + +Keep capture policy at bootstrap so development can retain more detail than a +high-volume production runtime. Read `trace-origins.md` for portable capture, +async origins, native boundaries, and source-map requirements. + +## Transport Dispatch + +The core behavior can remain conceptually small: + +```ts +function emit( + level: LogLevel, + message: string, + context?: LogContext, + error?: unknown, +): void { + const record: LogRecord = { + timestamp: Date.now(), + kind: "log", + level, + message, + scope, + ...(context === undefined ? {} : { context: { ...context } }), + ...(error === undefined ? {} : { error }), + } + + for (const transport of transports) { + try { + transport.write(record) + } catch { + reportTransportFailureOnce() + } + } +} +``` + +Do not copy this literally when the repository already owns the facade. The +important properties are one record, one fan-out step, independent transports, +and no generic processor chain between them. diff --git a/.claude/skills/logging/references/platform-transports.md b/.claude/skills/logging/references/platform-transports.md new file mode 100644 index 0000000..f23b5ea --- /dev/null +++ b/.claude/skills/logging/references/platform-transports.md @@ -0,0 +1,339 @@ +# Platform Transports + +Use this reference when adding or changing transport registration, browser or +native persistence, Electron or Electrobun renderer-to-host delivery, batching, +flushing, or teardown. + +## Contents + +- [Transport Contract](#transport-contract) +- [Early Logging](#early-logging) +- [Transport Failure](#transport-failure) +- [Web](#web) +- [React Native](#react-native) +- [Electron And Electrobun](#electron-and-electrobun) +- [File Persistence](#file-persistence) +- [Stack And Origin Preservation](#stack-and-origin-preservation) +- [Telemetry](#telemetry) +- [Tests](#tests) + +## Transport Contract + +Keep the caller-facing logger synchronous. An asynchronous transport owns its +queue and lifecycle: + +```ts +interface LogTransport { + write(record: LogRecord): void + flush?(): Promise<void> + dispose?(): Promise<void> +} +``` + +Register transports at the runtime composition root: + +```ts +const logging = createLogging({ + scope: { + runtime: "web", + }, + transports: [createConsoleTransport()], +}) + +const removePersistence = logging.addTransport( + createBrowserPersistenceTransport(), +) +``` + +`addTransport()` should return a removal function or another clear lifecycle +handle. Prevent duplicate registration during HMR, repeated bootstrap, tests, +or remounting. + +## Early Logging + +Make the configured facade importable before optional services initialize. +Keep a console transport available from the start. + +Do not add an unbounded hidden queue for records emitted before persistence or +RPC is ready. It is acceptable for early records to reach only the console. + +If an application must retain early records, make that bounded bootstrap buffer +an explicit project decision with a clear ownership and flush point. + +## Transport Failure + +One transport failure must not: + +- throw through the logger call; +- prevent another transport from receiving the record; +- recursively invoke the same logger; +- retry forever; +- grow an unbounded queue. + +A transport may emit one guarded, rate-limited `console.warn` describing its +own failure. Do not route that warning through the logger it is servicing. + +## Web + +A web runtime may compose: + +- a developer console transport; +- IndexedDB or another browser persistence transport; +- a remote logging or telemetry transport. + +Browser persistence should own its batching, retention, querying, export, and +cleanup behavior. Do not place IndexedDB knowledge in the logger facade. + +Treat an existing persisted schema as a compatibility contract. If the common +record changes from fields such as `t` and `tags` to `timestamp`, `scope`, and +`context`, adapt new records inside the persistence transport or perform an +explicit database migration. Do not silently make old IndexedDB records +unreadable. + +Treat page shutdown flushes as best effort. Use the repository's established +page lifecycle integration rather than claiming that every queued record is +durable. + +## React Native + +React Native usually presents one JavaScript runtime to application code, even +when persistence ultimately crosses a native module. + +Hide that bridge inside the React Native transport: + +```text +application logger + -> React Native transport + -> native logging or file capability +``` + +Feature code still imports the common configured facade. It must not call the +native persistence module directly for ordinary logging. + +Flush buffered records on the project's established application-background or +shutdown lifecycle when useful, without blocking user-visible transitions. + +Verify the filesystem API's relocation semantics before implementing rotation. +For example, APIs in which `move` mutates the source `File` object's URI can +accidentally make subsequent writes target the backup. Copying to the backup +and truncating the current file may be safer for that API. This is a +transport-specific decision, not logger-core behavior. + +## Electron And Electrobun + +Treat the privileged host and renderer as separate runtimes: + +```text +renderer feature + -> renderer logger facade + -> renderer console transport + -> bounded RPC transport + -> host logging handler + -> host-owned file transport + +host feature + -> host logger facade + -> host console transport + -> host-owned file transport +``` + +The host owns file paths, file creation, rotation, retention, reading, and +export. Do not grant the renderer filesystem access merely for logging. + +Use the repository's existing native-RPC foundation. The RPC client belongs +inside the renderer transport; feature modules do not import it. + +Only install the native transport when the bridge is actually available. +Storybook, browser previews, tests, or SSR may import the same configured +facade without a desktop host; those runtimes should retain console logging +without repeatedly failing native requests. + +### Batch Contract + +Prefer one ingestion method carrying a batch of structured records: + +```ts +const loggingMethods = { + writeBatch: "logging.writeBatch", +} as const + +interface WriteLogBatchParams { + records: RendererLogRecord[] +} +``` + +Reuse the repository's contract and validation system. Do not hand-maintain +parallel request shapes when a source of truth already exists. + +Register the method as ordinary native infrastructure. Do not expose log +ingestion to an agent or user-facing capability catalog unless a separate, +explicit product requirement calls for it. + +The wire record should preserve `originStack` and, for an actual error, a +minimal `errorText` and `errorStack`. Keep them as separate fields. Do not send +an `Error` instance, concatenate stacks, or introduce a generic domain error +serializer. + +The transport may apply the smallest wire-only representation change required +by the RPC implementation. Do not place that conversion in the core logger or +reuse it as a general application error model. + +### Renderer Queue + +The renderer RPC transport should: + +- preserve record order within a batch; +- cap its queue; +- flush on a short interval or batch-size threshold; +- avoid one RPC request per ordinary record; +- prefer dropping old `debug` records before more important records when full; +- expose a best-effort `flush()`; +- stop timers and reject new persistence work after `dispose()`; +- keep console logging available when RPC is unavailable. + +Exact batch sizes and intervals depend on the application. Keep them +configurable beside the transport rather than spreading constants through +feature code. + +### Host Ingestion + +The host handler receives records from the renderer and sends them directly to +the host-owned persistence sink. + +Do not call the host root logger again: + +```ts +// Avoid: creates a host record from a renderer record. +mainLogger.info(record.message, record.context) + +// Prefer: preserve the received renderer record. +fileTransport.write(record) +``` + +Re-logging can: + +- replace the renderer runtime with the host runtime; +- assign a second timestamp; +- duplicate console output; +- apply level filtering twice; +- create a loop when transports are composed incorrectly. + +The host's own application logs continue through its host root logger. + +### Infrastructure Failures + +Native RPC may itself need logging. Do not make successful delivery through the +same RPC channel the only way to diagnose its failure. + +Keep direct guarded console output available inside the logging and native-RPC +infrastructure. Avoid a cycle where an RPC failure logs through the failing RPC +transport indefinitely. + +## File Persistence + +The file transport owns: + +- record formatting, such as JSONL or readable text; +- file location; +- append and flush behavior; +- rotation and retention; +- file read/export capabilities; +- platform-specific filesystem errors. + +Reuse a public application-data path provider when one exists. Do not import a +different package's private runtime helper. When no public provider exists, +keep a minimal platform resolver inside the host transport or inject one from +the host composition root. + +Keep the stored representation stable enough for inspection, but do not turn +the file transport into a universal object or error serializer. + +Errors need a small boundary representation. Preserve the standard text and +stack of an actual `Error`; use the established small string fallback for other +values. Preserve an existing `originStack` independently. Scope and context +should already contain plain diagnostic values; if they are circular or +unsupported by the wire, dropping that record is preferable to adding a +recursive sanitizer to the shared logger. + +## Stack And Origin Preservation + +The transport must not invent a later origin: + +- a browser persistence transport keeps the facade-captured origin; +- a React Native native-file transport keeps the JavaScript error and origin + strings without depending on private LogBox or Metro APIs; +- a renderer RPC transport keeps renderer stacks; +- a desktop host writes received renderer stacks directly instead of replacing + them with handler or file-writer frames; +- a host-side failure remains a separate host error correlated by a safe request + identifier. + +Production persistence is useful only when the corresponding web, Node, +Hermes, or native source maps and symbols can be matched to the exact release +or OTA update. Read `trace-origins.md` for the capture helper, platform details, +symbolication, and verification matrix. + +## Telemetry + +A telemetry breadcrumb transport may receive ordinary structured records. +Apply the SDK's established filtering and lifecycle at that boundary. + +An application-event transport should receive only records explicitly marked +as events: + +```ts +const openReplayTransport: LogTransport = { + write(record) { + if (record.kind !== "event") return + + tracker.event(record.message, { + ...record.scope, + ...record.context, + level: record.level, + timestamp: new Date(record.timestamp).toISOString(), + }) + }, +} +``` + +Keep this transport in the provider integration package and register it at the +runtime composition root. Provider initialization, user identity, consent, and +reset remain explicit provider operations rather than logger methods. + +Do not automatically translate every `error` record into an exception incident. +Exception capture has different semantics and belongs to error reporting. + +Do not derive counters, timings, or analytics events from ordinary log messages +or levels. Use explicit typed instruments or event records. + +## Tests + +Use an in-memory transport to assert records without mocking the console: + +```ts +function createMemoryTransport() { + const records: LogRecord[] = [] + + return { + records, + write(record: LogRecord) { + records.push(record) + }, + } +} +``` + +Verify: + +- child scopes merge without mutating their parents; +- one method call creates one record; +- one broken transport does not block another; +- registration and removal are deterministic; +- queue limits and drop policy are enforced; +- flush sends the remaining batch; +- host ingestion preserves renderer metadata and timestamps. +- original error stacks remain unchanged after repeated transport delivery; +- origin stacks survive queues and renderer-to-host delivery as separate data; +- telemetry transports ignore ordinary records unless their policy explicitly + includes them; +- event identity survives any RPC or persistence boundary. diff --git a/.claude/skills/logging/references/proven-platform-patterns.md b/.claude/skills/logging/references/proven-platform-patterns.md new file mode 100644 index 0000000..b638c53 --- /dev/null +++ b/.claude/skills/logging/references/proven-platform-patterns.md @@ -0,0 +1,162 @@ +# Proven Platform Patterns + +Use these compact shapes when adapting the logging foundation to a concrete +client runtime. They are examples of the same contract, not four competing +logger designs. + +## Web + +Place a reusable facade in a package when several workspace modules or +applications can consume it. Keep browser persistence application-owned: + +```text +packages/logger + -> contract, factory, console transport + +apps/web/core/logger + -> configured web root + -> IndexedDB transport +``` + +The IndexedDB transport owns batching, retention, queries, export, and mapping +between the current common record and any legacy stored schema. + +New errors should receive the transport's minimal string fallback before JSONL +export. Preserve an actual error stack and any separately captured origin stack. +Other non-cloneable context still fails best effort; do not add a recursive +common serializer. + +## React Native + +In a single-application repository, prefer an application module over an +artificial workspace package: + +```text +core/logging/index.ts + -> facade and process-local root + +core/logging/transports/native-file.ts + -> buffer, JSONL, rotation, native filesystem +``` + +Initialize once before application features start. Register a best-effort flush +when the established app lifecycle leaves the active state. Skip the native +file transport on web. + +Keep the original `Error` for console and incident providers. Persist its +standard stack separately from a facade- or operation-captured origin. Release +and OTA-update traces require their matching Hermes source maps. Include stable +release correlation in the root scope, such as the application version, +runtime version, and update identifier; an OTA stack without its exact update +identity cannot be matched reliably to an artifact. + +Keep filesystem behavior inside the transport and test the installed API +version. In particular, determine whether move/rename operations mutate the +source object before using them for rotation. + +## Electron + +Use one package with explicit runtime entrypoints: + +```text +logger +├── index.ts +├── native/client.ts +├── native/main/index.ts +└── preload.ts +``` + +- `index.ts` is Electron-free. +- `native/client.ts` configures renderer console plus bounded batch RPC. +- `native/main/index.ts` configures main console plus file persistence and + registers `writeBatch`. +- `preload.ts` owns a console-only process-local root. + +The renderer transport should only be installed when the preload bridge exists, +so browser previews and Storybook remain valid consumers. The main handler +writes received records straight to the file transport, including renderer +error and origin stacks. + +Choose one incident policy. + +```ts +// Diagnostic logger and explicit incident reporter are separate. +log.error("Workspace load failed", error, { workspaceId }) +reporting.captureException(error) +``` + +Use that shape when some error logs are diagnostic-only. Ensure one owner calls +both operations once; do not add a second global observer for the same failure. + +Alternatively, a repository may define every error record as incident-worthy: + +```text +reportError(error) + -> one logger.error record + -> console and file transports + -> incident-provider transport +``` + +In that policy, the provider transport owns `captureException` and receives the +original local `Error`. Application reporting code must not also call the +provider SDK. If only selected errors are incidents, add an explicit record kind +or call option rather than deriving intent from text. + +When the renderer also uses OpenReplay or another session provider, keep its +event adapter in that provider package: + +```text +feature logger.event(...) + -> renderer logger + -> console and file transports + -> OpenReplay event transport +``` + +The provider transport always forwards explicit event records. Under the +repository's incident policy it may also forward eligible error records, +preserving the original `Error`, scope, context, and separate `originStack`. +Create a fallback `Error` only inside the provider boundary when its SDK +requires one and the record contains a non-Error value. + +Initialization, identity, consent, and reset remain provider control operations; +they are not log transports. + +## Electrobun + +Use the same package split, replacing Electron entrypoints with the repository's +Electrobun native-module conventions: + +```text +renderer logger + -> console + -> bounded writeBatch client + +Bun handler registry + -> validate batch + -> host file transport +``` + +Register `writeBatch` in the native handler/module registry. If the repository +also derives agent capabilities from handler definitions, leave logging +unexposed unless it has explicit capability metadata. + +The host file transport may resolve its own application-data location or +receive a public resolver from the composition root. Do not reach into another +package's private filesystem implementation. + +## Shared Proof Obligations + +Across all four variants, verify: + +- shallow scope and context snapshots; +- original local error identity before a boundary; +- boundary-local error text and standard stack without domain conversion; +- origin stacks kept separate from error stacks; +- bounded async queues and deterministic flush; +- independent transport failures; +- stable persisted schemas or explicit adapters; +- no native requests when the native bridge is absent; +- no host re-logging of renderer records. +- matching source maps or symbols for production releases and updates; +- explicit event identity preserved across transports; +- no accidental promotion of ordinary `info` logs into remote events. diff --git a/.claude/skills/logging/references/trace-origins.md b/.claude/skills/logging/references/trace-origins.md new file mode 100644 index 0000000..3cfc8cc --- /dev/null +++ b/.claude/skills/logging/references/trace-origins.md @@ -0,0 +1,308 @@ +# Trace Origins Across Client Runtimes + +Use this reference when logs or reported errors point to logger internals, +catch blocks, Query callbacks, queue flushes, RPC handlers, or minified bundles +instead of the code that initiated the operation. + +## Contents + +- [Keep Three Locations Distinct](#keep-three-locations-distinct) +- [Capture An Origin Portably](#capture-an-origin-portably) +- [Choose A Capture Policy](#choose-a-capture-policy) +- [Preserve Origins In Records](#preserve-origins-in-records) +- [Async And Framework Boundaries](#async-and-framework-boundaries) +- [Web](#web) +- [React Native](#react-native) +- [Electron And Electrobun](#electron-and-electrobun) +- [Production Symbolication](#production-symbolication) +- [Verification](#verification) + +## Keep Three Locations Distinct + +One diagnostic flow can contain three legitimate locations: + +1. **Failure stack**: where an `Error` was created or thrown. +2. **Operation origin**: where the caller initiated work that later crossed an + async, framework, worker, or RPC boundary. +3. **Observation location**: where a cache callback, reporter, transport, or + host received the result. + +The failure and operation origin are useful. The observation location is +usually infrastructure noise. Do not overwrite the first two with the third. + +For an ordinary log without an error, the operation origin is the logger call +site. For an error log, the original error stack remains primary and the logger +origin is supplemental. + +## Capture An Origin Portably + +`Error.stack` exists across common client engines, but its exact string format +and frame names differ. V8 exposes `Error.captureStackTrace`; Hermes and +JavaScriptCore compatibility must not be assumed. + +Use feature detection and keep the raw stack: + +```ts +export interface TraceOrigin { + readonly stack?: string +} + +type ErrorWithStack = { + stack?: string +} + +type ErrorConstructorWithCapture = ErrorConstructor & { + captureStackTrace?( + target: ErrorWithStack, + constructor?: (...args: never[]) => unknown, + ): void +} + +export function captureTraceOrigin(): TraceOrigin { + const target: ErrorWithStack = {} + const ErrorRuntime = Error as ErrorConstructorWithCapture + + if (typeof ErrorRuntime.captureStackTrace === "function") { + ErrorRuntime.captureStackTrace(target, captureTraceOrigin) + return { stack: target.stack } + } + + return { + stack: new Error("Operation origin").stack, + } +} +``` + +The fallback may retain the helper frame. That is better than deleting a fixed +number of lines and corrupting another engine's format. If a repository already +has a tested engine-specific frame filter, keep it at the presentation boundary, +not in the stored record. + +Capture before the discontinuity: + +```ts +const origin = captureTraceOrigin() + +queueMicrotask(() => { + runJob().catch(error => { + reportError(error, { originStack: origin.stack }) + }) +}) +``` + +Capturing inside `catch`, a queue flush, or an RPC handler only records that +observer. + +## Choose A Capture Policy + +Stack capture and source-map lookup have a cost. Do not add it blindly to every +high-volume production record. + +A practical starting policy is: + +- development: capture origins for every enabled level; +- production: capture for `warn`, `error`, and explicitly traced operations; +- error records: always preserve the original error object locally, regardless + of the origin policy; +- sampled or high-volume events: prefer stable scope and correlation identifiers + unless a measured investigation needs stacks. + +Make the policy configurable at the runtime composition root. Feature code must +not contain environment checks solely to decide whether a logger captures its +call site. + +The method-level options should also support an explicit opt-out: + +```ts +interface LogCallOptions { + readonly captureOrigin?: boolean + readonly originStack?: string +} +``` + +Use `captureOrigin: false` for infrastructure observers that have no genuine +earlier operation origin, such as a declarative Query cache callback, a global +error listener, an RPC ingestion handler, or a file writer. Their local stack +is an observation location, not a useful substitute for the caller. + +## Preserve Origins In Records + +Keep the record shape explicit: + +```ts +interface LogRecord { + readonly timestamp: number + readonly level: LogLevel + readonly message: string + readonly scope: LogFields + readonly context?: LogFields + readonly error?: unknown + readonly originStack?: string +} +``` + +Capture `originStack` at the public facade call, not inside `dispatch()` or a +transport. Allow an established reporter or operation wrapper to provide an +earlier origin captured before an async boundary. + +The distinction includes private facade helpers. If a public method delegates +automatic capture to a generic `emit()` or `resolveOrigin()` helper, that helper +becomes the leading frame. Capture directly while executing the public method, +then pass the raw stack into the private emitter. Do not trim the helper by a +fixed line count. + +Do not concatenate: + +```ts +// Avoid +error.stack += `\n--- called from ---\n${originStack}` +``` + +That mutates a potentially shared error, makes retries duplicate frames, and +produces a string that incident SDKs and source-map processors may parse +incorrectly. + +Local console and telemetry transports should receive the original `Error`. +At a serialization boundary, preserve only the standard diagnostic fields +needed by that boundary: + +```ts +interface WireLogRecord { + readonly errorText?: string + readonly errorStack?: string + readonly originStack?: string +} + +function toWireError(error: unknown) { + if (error instanceof Error) { + return { + errorText: `${error.name}: ${error.message}`, + errorStack: error.stack, + } + } + + return { + errorText: stringifyUnknownAtBoundary(error), + } +} +``` + +This is not a domain error serializer. Do not traverse causes, discover custom +properties, or maintain an error-class registry in logging infrastructure. +When extending a stored or wire schema, keep legacy fields readable until the +transport performs an explicit migration; new records can populate +`errorText`, `errorStack`, and `originStack` without rewriting old data. + +## Async And Framework Boundaries + +Modern engines can retain useful frames across some `await` chains, but timers, +event emitters, task queues, cache execution, workers, and RPC create real +discontinuities. Do not rely on one engine's current async-stack behavior as an +application contract. + +- Capture before scheduling a timer, background task, or queued callback when + the initiating caller matters. +- Give concurrent operations separate origins. Never store the latest origin in + one module-level or hook-level mutable variable. +- For TanStack Query, keep the rejection's original error. A stack captured + while defining a hook identifies registration, not necessarily the later + mutation invocation. A global MutationCache can capture synchronously in + `onMutate` and associate the origin with the Mutation instance; read the + TanStack Query reference below. +- For a React render failure, keep both the thrown error stack and React's + component stack; they answer different questions. +- Use safe operation or request identifiers to correlate logs across runtimes. + +Read `../../error-handling/references/tanstack-query.md` for reporting ownership +and mutation invocation details. + +## Web + +Pass an original `Error` as a value to the console transport instead of only +printing its message or preformatted stack string. Browser DevTools can then +inspect the actual error. + +The clickable location of a wrapped `console.*` call may still point to the +transport. Use the record's separately captured `originStack` when the facade +call site matters. Do not depend on DevTools-specific console formatting as the +only persisted diagnostic. + +Production bundles require source maps available to the chosen incident or log +inspection path. If public source maps are unacceptable, upload them privately +to the provider and remove or withhold deployed `.map` files according to the +build system's supported flow. + +## React Native + +Keep the original `Error` for the local console and incident SDK. Do not replace +it with `error.stack` before reporting; that turns the error into plain text and +can make the logger call look like the origin. + +React Native development tools and LogBox are development aids, not production +symbolication. Avoid private LogBox or Metro APIs for trimming or +symbolicating stacks inside application code. + +Hermes release stacks need the matching JavaScript/Hermes source maps. An OTA +update has a different JavaScript artifact from the embedded application build; +upload and identify maps for each update as well as each native build. + +## Electron And Electrobun + +Treat renderer and host stacks as separate runtime evidence. + +Renderer logging should send: + +- the renderer record and timestamp; +- renderer scope and safe correlation identifiers; +- minimal `errorText` and `errorStack`; +- the separately captured renderer `originStack`. + +The host persists those fields unchanged. It must not re-log the record and +replace the renderer origin with the ingestion handler. + +If a native handler itself fails, report the original failure in the host while +the host `Error` still exists. A renderer-facing RPC failure is a separate, +lossy contract and must not pretend to contain the host stack. Correlate both +sides with a safe request identifier when diagnosis needs the full path. + +For Electron main or another Node-based host, enable the runtime's supported +source-map integration before application modules load. Do not globally replace +`Error.prepareStackTrace` or rewrite stack strings unless the repository already +has a tested requirement for it. + +## Production Symbolication + +A captured stack is only an address list until it maps to the exact shipped +code. + +Verify: + +- source maps are generated for every minified or transpiled runtime; +- the release, build, distribution, and OTA update identifiers match the + uploaded artifacts; +- source maps are uploaded before using a synthetic production failure as a + test; +- private maps are not unintentionally published; +- a real test event resolves to original file, line, and function names; +- persisted raw stacks retain enough generated file, line, and column data for + later symbolication. + +Do not claim trace preservation is complete after a development-only console +test. + +## Verification + +Test at least: + +1. a synchronous thrown `Error`; +2. a caught and rethrown identical error; +3. a wrapper error with `cause`; +4. a rejection after an `await`; +5. a timer or queued task with a captured origin; +6. concurrent mutations or tasks with different origins; +7. a renderer record persisted by the host; +8. a host handler failure correlated with its renderer request; +9. a production or preview build symbolicated with its exact artifacts. + +Assert that the original error stack never gains appended sections after +retries, repeated logging, or transport delivery. diff --git a/.claude/skills/native-integration/SKILL.md b/.claude/skills/native-integration/SKILL.md new file mode 100644 index 0000000..f4460a0 --- /dev/null +++ b/.claude/skills/native-integration/SKILL.md @@ -0,0 +1,345 @@ +--- +name: native-integration +description: Use whenever work adds, changes, reviews, or debugs desktop native integration in Electron, Electrobun, or another host/webview runtime. Trigger for Electron main-process, preload, contextBridge, ipcMain/ipcRenderer, IPC channels, Electrobun BrowserView/Electroview RPC, native modules, handler definitions, typed renderer clients, method or event registries, capability metadata or catalogs, runtime validation schemas, host registration, filesystem or OS access, deep links, secure storage, updates, and exposing native operations to automation, agents, or MCP. +--- + +# Native Integration + +Treat Electron, Electrobun, and similar desktop runtimes as transport adapters +around the same modular native-RPC architecture: + +```text +shared contract + -> handler definitions + -> native feature module + -> central host registry + -> runtime transport + -> typed renderer client + +handler definitions + -> capability catalog + -> optional automation, agent, or MCP consumers +``` + +The renderer is sandboxed. The host owns privileged work. A typed, validated, +inspectable native registry is the boundary between them. + +First inspect the repository's existing native-RPC foundation and one complete +feature module. Extend that pattern. Do not introduce a parallel raw IPC system. + +## Start From A Feature Module, Not A Channel + +A native integration is a feature with a contract, handlers, metadata, and a +client. It is not merely an IPC string. + +Examples include: + +- filesystem and operating-system access; +- secure storage and keychain operations; +- windows, dialogs, notifications, and shell operations; +- deep links and application lifecycle; +- application updates; +- local services and native addons. + +Each feature owns its contract and runtime implementation. The application +composes features through one registry. Transport-specific wiring must not +become the owner of feature behavior. + +## Prefer Package-Owned Native Features + +If a native feature can be isolated behind a coherent contract, place it in +`packages/`. This is the default, not an optimization reserved for features +already shared by multiple applications. + +The package should own: + +- its shared contract and method registry; +- its handler definitions and host implementation; +- its typed renderer client; +- its runtime-specific entrypoints when required. + +The application should only compose and register the package. Keep a feature +inside an application only when it is genuinely inseparable from that +application. + +An illustrative package may expose surfaces such as: + +```text +packages/{capability}/ +└── src/ + ├── index.ts + └── native/ + ├── index.ts + ├── client.ts + ├── main/ + │ └── index.ts + └── bun/ + └── index.ts +``` + +This is not a mandatory literal tree. Use only the runtime entrypoints the +project needs. Apply **file-structure** recursively to decide exact placement, +capability subfolders, public entrypoints, and local internals. + +Shared and renderer entrypoints must never import host-only dependencies. + +## Define One Shared Contract + +Declare every callable method once in a feature-owned registry: + +```ts +export const nativeMethods = { + chooseDirectory: "workspace:choose-directory", + readMetadata: "workspace:read-metadata", +} as const; +``` + +The contract must provide: + +- a stable, feature-namespaced wire method; +- the request parameters for that method; +- the response for that method; +- runtime schemas for both request and response. + +Prefer deriving TypeScript types from the schemas or another existing source of +truth. Do not separately hand-maintain method strings, DTOs, schemas, host +signatures, and renderer signatures. + +Runtime validation of both parameters and responses is mandatory at the native +boundary. An exception is acceptable only when the established transport +already guarantees the exact runtime-validated contract or the value cannot +meaningfully be represented by the project's schema system. Document that +exception beside the definition. `void` input or output is still an explicit +contract, not an omitted one. + +Do not create a generic `types.ts` dump. Keep each contract with the capability +that owns it. + +## Define Inspectable Handlers + +Every request handler definition should carry enough information to register, +validate, execute, inspect, and expose it without reconstructing knowledge +elsewhere: + +- a stable name; +- its method from the shared registry; +- parameter and response schemas; +- an execution policy, including timeout behavior; +- a host-context factory or explicit dependencies; +- capability metadata. + +Conceptually: + +```ts +const chooseDirectory = defineNativeHandler({ + name: "chooseDirectory", + method: nativeMethods.chooseDirectory, + params: chooseDirectoryParams, + response: chooseDirectoryResponse, + execution: { timeoutMs: 30_000 }, + capability: { + id: nativeMethods.chooseDirectory, + title: "Choose workspace directory", + description: "Opens the native directory picker.", + exposure: false, + requiresApproval: true, + }, + createContext: createWorkspaceContext, + handle: async ({ params, context }) => { + // Privileged feature logic belongs here or in an injected service. + }, +}); +``` + +Names and helper signatures are illustrative. Reuse the repository's native-RPC +primitives instead of recreating this API. + +Validate at the boundary. Keep privileged logic in the handler or an injected +host service, never in preload code or renderer code. + +Use bounded timeouts by default. Disable a timeout only for an operation whose +lifecycle is legitimately open-ended, such as waiting for a native user prompt, +and make that decision explicit. + +## Make Capabilities Universal And Exposure Explicit + +Every registered handler must include capability metadata even when its only +current consumer is the renderer. + +At minimum, metadata should make these facts discoverable: + +- stable capability id; +- human-readable title and description; +- safety or effect classification used by the project; +- whether explicit approval is required; +- whether the capability may be exposed outside the ordinary renderer client. + +Exposure is opt-in. The default must be `false`, `none`, or the repository's +equivalent. Registration makes a handler callable by the trusted application +client; it does not automatically make it available to automation, agents, or +MCP. + +Derive the capability catalog from handler definitions and their schemas. Do +not maintain a second manual registry. Automation, an agent, MCP, a command +palette, or another future consumer may read the catalog, but none of them owns +the native architecture. + +Operations with destructive effects, external side effects, sensitive data, or +arbitrary native-tool execution should require approval according to the +project's policy. + +## Build A Native Feature Module + +A native module aggregates the feature boundary: + +- module name; +- method registry; +- request map; +- handler loader or handler map; +- handler definitions and capability metadata; +- typed renderer client. + +The module must not know which application will register it. Host-only handlers +may load lazily to keep native dependencies out of shared and renderer bundles. + +Registration should fail clearly for duplicate methods, missing handlers, or a +contract that cannot be validated. Do not silently allow incomplete modules. + +## Keep One Reviewable Host Registry + +The host application should have one central registry that composes all native +modules. Adding an isolated feature should require one obvious registry entry, +not edits across unrelated switches and transport files. + +The registry should be able to: + +- combine request contracts; +- load and dispatch handlers by method; +- enumerate handler definitions and capability metadata; +- detect duplicate or missing registrations. + +Register the complete host surface before the renderer or webview can call it. +Application-owned registry code is wiring only; feature logic remains in its +package. + +## Generate A Thin Renderer Client + +Build the renderer client from the same request map and method registry used by +the host. + +The renderer: + +- imports a client-safe entrypoint such as `native/client`; +- calls feature methods, not raw transport channels; +- never imports host entrypoints, native addons, or `node:*`; +- never repeats method strings or request/response types; +- guards native-only access when the project also runs in a browser, test, or + Storybook environment. + +Keep transport errors in their native form unless the project already defines a +specialized error registry or conversion flow. Follow that established flow +when it exists. Never invent a new normalization layer inside a feature client. + +## Adapt Only The Transport + +The feature module and contract remain conceptually the same across runtimes. +Only the adapter changes. + +| Runtime | Host adapter | Renderer boundary | +| --- | --- | --- | +| Electron | Register the combined request map through `ipcMain.handle` or the repository's wrapper | Expose one narrow preload bridge through `contextBridge`; the typed feature client invokes it | +| Electrobun | Register the combined schema and handlers through the project's BrowserView RPC wrapper | Construct the typed client through Electroview RPC or the repository's wrapper | + +For Electron, prefer one shared bridge surface over a new `window.*` global for +every feature. Preload forwards validated typed calls; it contains no feature +business logic. + +For Electrobun, keep Bun-only handlers behind the Bun runtime entrypoint and +webview-safe client code behind the client entrypoint. + +Follow the exact runtime API and versions already installed in the repository. +Do not make feature packages depend on transport details that belong in the +adapter. + +## Treat Requests And Events As Separate Contracts + +Request-response is the default for commands and queries: send typed parameters +and receive one typed result. + +Events are first-class for host-pushed information such as: + +- deep-link navigation; +- update or download progress; +- native lifecycle changes; +- long-running operation progress; +- notifications produced outside a renderer request. + +Define a typed event registry and payload map rather than scattering event +strings. Every renderer subscription must return an unsubscribe function that +removes the exact listener it registered. + +Do not emulate request-response with ad hoc event pairs. Do not emulate a native +event stream with polling or repeated invokes. + +How a component subscribes and manages its lifecycle remains a React concern; +follow **components**, including its strict guidance on avoiding `useEffect`. + +## Preserve The Privilege Boundary + +- The renderer never accesses the filesystem, OS, native addons, keychain, or + host lifecycle directly. +- Validate untrusted request parameters before privileged work and validate the + result before returning it across the boundary. +- Prefer narrow operations over arbitrary primitives: for example, a specific + trusted URL operation instead of unrestricted shell execution. +- Inject host context such as windows, services, paths, and application metadata + rather than importing ambient global state throughout handlers. +- Follow the project's existing logging and error policy. Do not leak secrets, + sensitive native details, or arbitrary host error payloads across the bridge. + +## Implementation Workflow + +1. Inspect the native-RPC foundation, central registry, and one complete native + feature already present in the repository. +2. Decide whether the feature can be isolated into a package. Prefer a package + whenever it can. +3. Define the method registry, request map, and runtime schemas. +4. Implement handler definitions with execution policy, host context, and + capability metadata. +5. Aggregate them into a native feature module. +6. Derive or build the typed renderer client from the same contract. +7. Add one explicit entry to the central host registry. +8. Add a typed event contract only when the host must push information. +9. Test contract validation, handler behavior, registry completeness, client + typing, and subscription cleanup as applicable. +10. Run the repository's focused formatting, linting, type-checking, tests, and + native build checks. + +## Avoid + +- scattered raw `ipcMain`, `ipcRenderer`, or RPC method strings; +- a separate preload global for every feature; +- duplicate request/response types on opposite sides of the boundary; +- handlers without parameter or response validation; +- registered handlers without capability metadata; +- exposing registered handlers to agents, MCP, or automation by default; +- large application-level switches that own feature dispatch; +- renderer imports from host runtime entrypoints; +- host imports leaking into shared or client bundles; +- keeping an isolatable native feature in an application instead of `packages/`; +- a generic native, handlers, or types dumping ground. + +## Related Skills + +- Exact package and feature-internal placement, visibility, and entrypoints → + **file-structure**. +- Native client errors and external-service access → **api-integration**. +- Secure persisted state consuming a native provider → **state-management**. +- Native deep links entering the route tree → **routing**. +- Host and renderer diagnostic delivery → **logging**. +- Native failure propagation, exception reporting, and runtime boundaries → + **error-handling**. +- Native-originated user notifications and recovery presentation → + **user-feedback**. +- Formatting, linting, type-checking, tests, and native build checks → + **code-quality**. diff --git a/.claude/skills/routing/SKILL.md b/.claude/skills/routing/SKILL.md new file mode 100644 index 0000000..e46a726 --- /dev/null +++ b/.claude/skills/routing/SKILL.md @@ -0,0 +1,330 @@ +--- +name: routing +description: Use whenever work adds, changes, moves, debugs, or reviews application routes, route or page directories, URL paths, path or search params, navigation, redirects, layouts, outlets, guards, loaders, route-tree composition, router context or history, deep-link mapping, not-found behavior, or routed Storybook/test harnesses. Also use when deciding where a route or page belongs, even if the requested change is described only as a file move or a new screen. +--- + +# Routing + +Model application navigation as one explicit, typed tree. Keep URL contracts, +route declarations, page components, and tree composition separate enough that +each has one owner and the import graph stays acyclic. + +## Inspect The Existing Router First + +Before changing routes: + +1. Find the router entrypoint, root route, top-level branches, guards, and + not-found handling. +2. Trace the concrete parent chain of the affected page. +3. Inspect how the project validates params/search, loads route data, lazy-loads + components, and constructs links. +4. Check platform constraints such as browser, hash, or memory history and any + deep-link adapter. +5. Preserve a coherent local router convention unless this task explicitly + corrects or migrates it. + +Do not infer routing from directory names alone. The router definition is the +runtime source of truth. + +## Keep The Route Tree At The Source Root + +Prefer one `routes/` directory at the application source root: + +```text +src/ +├── api/ +├── routes/ +│ ├── __root.tsx +│ ├── router.tsx +│ ├── guards.ts +│ ├── app/ +│ ├── auth/ +│ └── local-auth/ +└── ui/ +``` + +Do not nest this root under an application branch and produce shapes such as +`app/routes/app`. `routes/app`, `routes/auth`, and `routes/local-auth` are clear +siblings in one navigation tree. + +These directories represent routing scopes, not necessarily literal URL +segments. For example, `routes/app` may be a pathless authenticated layout with +an internal id, while `routes/auth` may own the real `/auth` segment. Choose +branch names for the boundary they own; define the URL explicitly in the route. + +If the framework mandates filesystem routing, adapt the filenames it requires +while preserving the ownership and composition rules in this skill. + +## Treat URLs As Resource Contracts + +- Use stable, resourceful paths: `/projects` and `/projects/$projectId`. +- Put resource identity in path params. +- Put filters, sorting, tabs, pagination, and view options in search params. +- Keep transient component state outside the URL only when it has no navigation, + sharing, refresh, or history meaning. +- Treat import/export formats and persisted files as data contracts, not routes. +- Define redirects deliberately; do not use them to conceal an incoherent tree. + +A page directory mirrors ownership in the route tree, but it does not create a +route by filesystem magic unless the selected router explicitly works that way. + +## Use Three Deliberate Directory Shapes + +### Layout or guard branch + +Use a route-owning branch when descendants share a layout, guard, or URL segment: + +```text +routes/app/pages/Projects/ +├── index.tsx # ProjectsLayout with Outlet +├── route.tsx # /projects +├── route.tree.ts # List route + Item subtree +├── components/ # shared by project routes +└── pages/ + ├── List/ + │ ├── index.tsx # project list page + │ └── route.tsx # /projects + └── Item/ + ├── index.tsx # ProjectLayout with Outlet + ├── route.tsx # /projects/$projectId + ├── route.tree.ts # index redirect + item subpages + ├── components/ # shared by one project's subpages + └── pages/ + ├── Overview/ + │ ├── index.tsx + │ └── route.tsx # /projects/$projectId/overview + └── Workspace/ + ├── index.tsx + └── route.tsx # /projects/$projectId/workspace +``` + +`Projects/index.tsx` is a layout because `List` and `Item` are its route +children. `Item/index.tsx` repeats the same role one level deeper because +`Overview` and `Workspace` are its children. The pattern is recursive: any leaf +may become a layout boundary when it gains real child pages. + +Use contextual page names inside an established owner. `Projects/pages/Item` +means the selected project route; keep `ProjectItem` for a list row/card +component and `projectId` for the route parameter. Name rendered components by +their UI role, such as `ProjectsLayout`, `ProjectListPage`, `ProjectLayout`, and +`ProjectOverviewPage`. + +### Page with direct subpages + +When a page is not a resource collection and has no selected-id level, omit the +artificial `List` and `Item` layers: + +```text +routes/app/pages/Project/ +├── index.tsx # ProjectLayout with Outlet +├── route.tsx # /project + explicit index route +├── route.tree.ts +└── pages/ + ├── Overview/ + │ ├── index.tsx + │ └── route.tsx # /project/overview + ├── Workspace/ + │ ├── index.tsx + │ └── route.tsx # /project/workspace + └── Settings/ + ├── index.tsx + └── route.tsx # /project/settings +``` + +Define what happens at `/project` explicitly. It may redirect to `Overview`, +`Workspace`, `Settings`, or another existing child, but there is no universal +default: choose the destination at this boundary from the product's business +logic. Never infer it from child order or silently treat the first registered +route as the default. + +Keep the redirect local and visible: + +- use an owned index route declaration when the decision is available from + router context, a guard, a loader, or static product policy; +- render a named index-forwarder component when the decision requires + render-time state or hooks; +- let `route.tree.ts` register that index route with the subpages, but keep the + redirect decision out of tree-composition code; +- keep the ordinary layout component focused on shared UI and its `Outlet`. + +### Terminal page without subpages + +Treat `Overview`, `Workspace`, and `Settings` in the preceding example as leaf +pages: each is a terminal route with no child routes, `Outlet`, or +`route.tree.ts`. A leaf page normally owns: + +- `index.tsx`: the page component only; +- `route.tsx`: the route declaration, schema, guard/loader when leaf-specific, + and lazy component binding. + +Private page components, hooks, and other implementation go inward under the +same page boundary according to **file-structure**. When a leaf gains real +subpages, promote it to the layout-branch shape and apply the same structure +recursively. + +For example, keep components used only by `Overview` inside that page: + +```text +Overview/ +├── index.tsx +├── route.tsx +└── components/ + ├── ProjectSummary.tsx + └── ActivityPanel/ + ├── index.tsx + └── components/ + └── ActivityRow.tsx +``` + +`routing` owns the `Overview` page boundary. **file-structure** owns everything +inside it: place each component, hook, schema, or other implementation at the +narrowest boundary containing all of its consumers, and repeat that rule +recursively. + +### Structural group without a route + +When sibling pages need grouping but share no URL segment, guard, or layout, do +not invent a wrapper route: + +```text +routes/auth/pages/PasswordRecovery/ +├── route.tree.ts +└── pages/ + ├── RequestReset/ + │ ├── index.tsx + │ └── route.tsx + └── ResetPassword/ + ├── index.tsx + └── route.tsx +``` + +Its `route.tree.ts` exports the sibling route collection for the nearest real +parent to compose. A directory is not entitled to a route merely because it +exists. + +## Separate Declaration, Rendering, And Composition + +Give each routing file one job: + +- `route.tsx` declares only routes owned by that boundary. It imports its + concrete parent and lazy-loads the page/layout component. It never imports or + assembles descendant routes. +- `index.tsx` renders only the page or layout. A layout renders an `Outlet`; a + leaf renders its screen. It does not assemble the route tree. +- `route.tree.ts` imports sibling/descendant route objects and composes them with + their parent. It contains no screen UI, schemas, data lookup, or route-specific + branching. +- `router.tsx` attaches top-level branch trees to the root and owns global router + configuration. + +This keeps the structural import graph flowing child declaration -> concrete +parent declaration, then composition -> children. Parent declarations never +import their children, so page components can safely import their own route +object for typed hooks. + +Do not use barrels to aggregate routes. Import the exact `route` or `route.tree` +module so parentage and ownership remain visible. + +## Assemble Recursively + +Compose each subtree at the narrowest boundary that owns all of its children, +then pass one tree or a small route collection upward: + +```text +router.tsx +└── app/route.tree.ts + └── Projects/route.tree.ts + ├── List/route.tsx + └── Item/route.tree.ts + ├── route.tsx # optional item index redirect + ├── Overview/route.tsx + └── Workspace/route.tsx +``` + +Do not centralize every leaf in `router.tsx`. Do not declare child paths in a +parent component. Do not widen typed route collections to a generic route type +merely to make composition compile; preserve inferred route identities. + +Routing-specific placement is authoritative over **file-structure** for route +branches, pages, declarations, and tree assembly. Apply **file-structure** +recursively inside the selected page or layout boundary. + +## Put Behavior At The Narrowest Route Boundary + +- Put a guard on the nearest ancestor whose entire subtree shares the rule. +- Put a loader on the route that owns the navigation dependency. Reuse the + project's API query definitions and cache instead of building a second fetch + or cache policy inside the loader. +- Put a search schema beside the leaf that reads it. +- When sibling leaves consume the same search contract, put it in a small module + at their nearest shared boundary; do not force it into a parent declaration + that would create a parent-child import cycle. +- Keep global router context and history selection at the router root. +- Treat platform history choices as adapters: browser history is not universally + correct, and hash history is not universally correct. + +Reuse the project's established error, auth, preload, cache, and redirect flows. +Do not invent a parallel routing policy inside one page. + +## Read Route State Through The Route Contract + +Prefer the route object's typed params/search hooks when the router supports +them. Avoid handwritten route ids, string casts, and generic APIs that discard +which route owns a value. + +Pathless layouts can contribute internal ids without contributing URL segments. +Therefore an internal route id may differ from the visible path. Code and routed +test harnesses must preserve that distinction. + +Use a loose/non-strict route-state read only for genuinely route-agnostic shared +behavior, and accept the reduced per-route typing intentionally. + +## Navigate Semantically + +- Use the router's `Link` for user-initiated navigation so accessibility, + history, preloading, and modifier-click behavior remain intact. +- Use programmatic navigation for event outcomes and side-effect redirects such + as successful submission, authentication changes, or guard resolution. +- Construct destinations with typed `to`, `params`, and `search` values rather + than concatenating URL strings. +- Keep sidebar/header navigation separate from route registration. A valid route + does not automatically belong in primary navigation. +- Map platform deep links into the same route contracts. Keep transport and + operating-system event handling in the platform integration boundary instead + of creating a second navigation tree. + +## Preserve Real Routes In Tests And Stories + +Pages that read route-owned params or search values need an active match with +the same effective route id and parent chain. A flat memory router around the +component is insufficient when pathless ancestors contribute ids. + +Build the smallest parallel route chain that preserves the production ids, then +provide page chrome as a shell. Do not weaken production route typing or add +fallback params merely to make a story render. + +## Verify The Contract + +After a routing change: + +1. Search for stale paths, route ids, imports, links, redirects, deep-link + mappings, and navigation entries. +2. Typecheck the registered route tree without generic widening or casts. +3. Exercise direct entry, refresh, back/forward history, links, and + programmatic redirects. +4. Verify valid and invalid path/search params, guards, loaders, and not-found + behavior. +5. Run affected routed tests and stories, then the repository's required + lint/tests/build. + +Read [references/tanstack-router.md](references/tanstack-router.md) when the +project uses TanStack Router, when pathless layout ids are involved, or when a +routed Storybook/test harness must reproduce production route identity. + +## Skill Boundaries + +- Route loader queries and cache ownership → **api-integration**. +- Page and layout component implementation → **components**. +- Non-routing placement inside a page boundary → **file-structure**. +- Native deep-link transport and event handling → **native-integration** or + the project's platform integration skill. diff --git a/.claude/skills/routing/agents/openai.yaml b/.claude/skills/routing/agents/openai.yaml new file mode 100644 index 0000000..706ce38 --- /dev/null +++ b/.claude/skills/routing/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Application Routing" + short_description: "Own route contracts, page trees, and navigation" + default_prompt: "Use $routing to design or change application routes, page hierarchy, guards, params, navigation, or route-tree composition." diff --git a/.claude/skills/routing/references/tanstack-router.md b/.claude/skills/routing/references/tanstack-router.md new file mode 100644 index 0000000..aac46e1 --- /dev/null +++ b/.claude/skills/routing/references/tanstack-router.md @@ -0,0 +1,343 @@ +# TanStack Router Patterns + +Use these examples only when the project uses TanStack Router. The directory and +ownership rules remain applicable with other routers, but their APIs will differ. + +## Contents + +- Root and top-level branches +- Pathless guarded branches +- Recursive route layouts +- Direct subpage layouts and landing redirects +- Typed leaf state +- Recursive composition +- Shared search contracts +- Routed Storybook and tests + +## Root And Top-Level Branches + +Keep the root route, router creation, and branch assembly at `src/routes/`: + +```tsx +// routes/__root.tsx +export const rootRoute = createRootRouteWithContext<RouterContext>()({ + component: RootLayout, +}) + +// routes/router.tsx +const routeTree = rootRoute.addChildren([appTree, authTree, catchAllRoute]) + +export const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} +``` + +The exact router options are project decisions. For example, a desktop +application loaded from a file protocol may need hash history, while a hosted +web application normally uses browser history. Copy the boundary, not another +project's platform choice. + +## Pathless Guarded Branch + +An authenticated application branch may be pathless: + +```tsx +// routes/app/route.tsx +export const appLayoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: 'app', + beforeLoad: requireAuthenticated, + component: lazyRouteComponent(() => import('./index')), +}) + +export const appIndexRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/', + component: lazyRouteComponent(() => import('./pages/Home')), +}) +``` + +```tsx +// routes/app/index.tsx +export default function AppLayout() { + return ( + <AppShell> + <Outlet /> + </AppShell> + ) +} +``` + +The `app` id participates in internal route identity but not in the visible URL. +Do not assume the route id of `/projects/$projectId` is equal to that visible +path when it descends from the pathless branch. + +## Recursive Route Layouts + +Give `/projects` a layout route when both its list and selected item are child +routes: + +```tsx +// routes/app/pages/Projects/route.tsx +export const projectsRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/projects', + component: lazyRouteComponent(() => import('./index')), +}) + +// routes/app/pages/Projects/pages/List/route.tsx +export const Route = createRoute({ + getParentRoute: () => projectsRoute, + path: '/', + component: lazyRouteComponent(() => import('./index')), +}) + +// routes/app/pages/Projects/pages/Item/route.tsx +export const projectItemRoute = createRoute({ + getParentRoute: () => projectsRoute, + path: '$projectId', + component: lazyRouteComponent(() => import('./index')), +}) + +export const projectItemIndexRoute = createRoute({ + getParentRoute: () => projectItemRoute, + path: '/', + beforeLoad: ({ params }) => { + throw redirect({ + to: '/projects/$projectId/overview', + params, + }) + }, +}) +``` + +```tsx +// routes/app/pages/Projects/index.tsx +export default function ProjectsLayout() { + return <Outlet /> +} + +// routes/app/pages/Projects/pages/Item/index.tsx +import { projectItemRoute } from './route' + +export default function ProjectLayout() { + const { projectId } = projectItemRoute.useParams() + + return ( + <ProjectProvider projectId={projectId}> + <Outlet /> + </ProjectProvider> + ) +} +``` + +`Projects` owns the `/projects` segment and shared project-area layout. `List` +is its index child. `Item` owns `$projectId` and becomes another layout because +it has `Overview` and `Workspace` children. If `Item` has no child pages, omit +its `route.tree.ts` and `pages/`; its `index.tsx` is then the leaf screen. + +An item index may render `Overview` at path `/`, or redirect +`/projects/$projectId` to an explicit `overview` child. Choose one canonical URL +and define that index behavior in the item route boundary. + +## Direct Subpage Layouts And Landing Redirects + +Omit collection/item layers when the route is an ordinary page with direct +subpages: + +```tsx +// routes/app/pages/Project/route.tsx +export const projectRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: '/project', + component: lazyRouteComponent(() => import('./index')), +}) + +export const projectIndexRoute = createRoute({ + getParentRoute: () => projectRoute, + path: '/', + component: lazyRouteComponent( + () => import('./index'), + 'ProjectIndexForwarder', + ), +}) +``` + +```tsx +// routes/app/pages/Project/index.tsx +export default function ProjectLayout() { + return <Outlet /> +} + +export function ProjectIndexForwarder() { + const canOpenWorkspace = useCanOpenWorkspace() + + return ( + <Navigate + replace + to={canOpenWorkspace ? '/project/workspace' : '/project/overview'} + /> + ) +} +``` + +```tsx +// routes/app/pages/Project/route.tree.ts +import { Route as projectOverviewRoute } from './pages/Overview/route' +import { Route as projectSettingsRoute } from './pages/Settings/route' +import { Route as projectWorkspaceRoute } from './pages/Workspace/route' +import { projectIndexRoute, projectRoute } from './route' + +export const projectTree = projectRoute.addChildren([ + projectIndexRoute, + projectOverviewRoute, + projectWorkspaceRoute, + projectSettingsRoute, +]) +``` + +The index route is mandatory when the layout route itself has no screen. The +destination is not prescribed: select an existing child from local business +rules. Use an inline redirect or route lifecycle function when the choice is +static or available before render. Use a named index-forwarder component when +the choice requires React state or hooks. In both cases, keep the redirect +explicit and keep `route.tree.ts` limited to composition. + +## Typed Leaf State + +Keep each leaf route object at module level and let its component import it: + +```tsx +// routes/app/pages/Projects/pages/Item/pages/Overview/route.tsx +const searchSchema = z.object({ + tab: z.enum(['summary', 'history']).optional().catch('summary'), +}) + +export const Route = createRoute({ + getParentRoute: () => projectItemRoute, + path: 'overview', + validateSearch: searchSchema, + component: lazyRouteComponent(() => import('./index')), +}) +``` + +```tsx +// routes/app/pages/Projects/pages/Item/pages/Overview/index.tsx +import { Route } from './route' + +export default function ProjectOverviewPage() { + const { projectId } = Route.useParams() + const { tab } = Route.useSearch() + + return <ProjectOverview projectId={projectId} tab={tab} /> +} +``` + +Do not replace this with `getRouteApi('<guessed-id>')`, +`useParams({ from: '<guessed-id>' })`, or a cast. A pathless ancestor can make a +guessed id wrong at runtime even when weakened types let it compile. + +`useSearch({ strict: false })` is acceptable only in route-agnostic shared code +such as a generic URL-filter hook. It trades route-specific guarantees for +portability. + +## Recursive Composition + +A route declaration imports its concrete parent. A tree module imports children: + +```tsx +// routes/app/pages/Projects/pages/Item/route.tree.ts +import { Route as overviewRoute } from './pages/Overview/route' +import { Route as workspaceRoute } from './pages/Workspace/route' +import { projectItemIndexRoute, projectItemRoute } from './route' + +export const projectItemTree = projectItemRoute.addChildren([ + projectItemIndexRoute, + overviewRoute, + workspaceRoute, +]) +``` + +```tsx +// routes/app/pages/Projects/route.tree.ts +import { projectItemTree } from './pages/Item/route.tree' +import { Route as projectListRoute } from './pages/List/route' +import { projectsRoute } from './route' + +export const projectsTree = projectsRoute.addChildren([ + projectListRoute, + projectItemTree, +]) +``` + +For an organizational group with no route of its own, export the leaves as a +collection and spread them into the real parent: + +```tsx +// routes/auth/pages/PasswordRecovery/route.tree.ts +export const passwordRecoveryRoutes = [ + requestResetRoute, + resetPasswordRoute, +] + +// routes/auth/route.tree.ts +export const authTree = authRoute.addChildren([ + signInRoute, + ...passwordRecoveryRoutes, +]) +``` + +Do not annotate these values as `AnyRoute[]`. Generic widening erases the route +tree information that powers typed ids, params, search, links, and navigation. +Let inference preserve the concrete tuple/array types. + +Avoid generic factories such as +`createProjectRoutes<TParentRoute extends AnyRoute>(parent)`. They obscure the +real parent and make route identity easier to widen accidentally. Import the +concrete parent route instead. + +## Shared Search Contracts + +A search schema belongs to the route that reads it. If two sibling routes share +the same external query contract, place it in a sibling module: + +```text +routes/auth/ +├── search-schema.ts +└── pages/ + ├── AcceptInvite/route.tsx + └── SignUp/route.tsx +``` + +Both leaves import the schema. The parent `route.tsx` does not import from its +children, so the route graph stays acyclic. + +Validate search values from external URLs and deep links as untrusted input. +Choose strict, coercing, defaulting, or catch behavior according to the actual +contract; do not silently normalize values merely because another project did. + +## Routed Storybook And Tests + +`Route.useParams()` and `Route.useSearch()` resolve against the active match's +effective id. When the production page is below `id: 'app'`, a flat test route +with only the visible path does not provide the same match. + +Create a minimal parallel chain: + +```text +test root +└── pathless layout (id: app) + └── leaf (same visible full path) +``` + +The route objects need not be identical; the effective route-id chain must +match. Render shared application chrome around the routed outlet as test/story +shell content. This preserves production page code and its typed hooks. diff --git a/.claude/skills/state-management/SKILL.md b/.claude/skills/state-management/SKILL.md new file mode 100644 index 0000000..cf5bb12 --- /dev/null +++ b/.claude/skills/state-management/SKILL.md @@ -0,0 +1,263 @@ +--- +name: state-management +description: Use whenever work creates, changes, reviews, debugs, or consumes shared client state, especially Zustand stores, selectors, actions, store namespaces, feature or page stores, store factories and providers, persisted state, migrations, hydration guards, secure storage, authentication state, cross-store workflows, resets, or long-running client processes. Trigger when deciding whether state belongs in Zustand, TanStack Query, router params or search, React Hook Form, React state, or context, even if the request does not name a state-management library. +--- + +# State Management + +Use Zustand for **shared client-owned state**, not as the default owner of every +value. Choose the owner and lifetime before choosing store syntax. + +Adapt imports and storage implementations to the repository. Preserve these +ownership and security boundaries even when the local Zustand idiom differs. + +## Decide The Owner First + +Keep each kind of state with its real authority: + +- Refetchable external or backend data belongs to the query layer. Do not copy + query results into Zustand through `useEffect`. +- Resource identity belongs in path params. Filters, sorting, pagination, tabs, + and other navigable state belong in search params. +- Form fields, validation, dirty state, and submission state belong to the form. +- State used by one component belongs in React state. +- State shared only by one composable subtree usually belongs in context. +- Shared client state, imperative client state, state that crosses unrelated + React branches, and client processes that outlive one component may belong in + Zustand. + +Persistence is a separate decision. A value does not belong in Zustand merely +because it must survive a restart, and a Zustand value need not be persisted. + +Read `references/ownership-and-scope.md` when choosing between these owners or +between a singleton and a scoped store. + +## Build Bounded Capability Stores + +Create a store around one cohesive client capability. Do not merge unrelated +state merely to reduce the number of stores, and do not split one atomic +capability into a store per field. + +A module-level `create(...)` is a singleton regardless of where its file lives. +Use it only when the capability truly has one application-wide or +feature-wide lifetime. Use a `createStore` factory with context when state: + +- belongs to one mounted page, editor, or component instance; +- is initialized from props; +- must reset on unmount; +- may have multiple simultaneous instances; +- must be isolated per SSR request, test, or story. + +Locate the store at the narrowest boundary containing all of its consumers. +Follow **file-structure** for the concrete directory shape. + +## Choose The Store Definition Deliberately + +Prefer `create(combine(initialState, actions))` for a small store whose inferred +shape stays obvious. Use an explicit store type when it clarifies a factory, +public contract, complex async lifecycle, middleware stack, or process manager. +Never force `combine` merely to avoid writing a useful type. + +Keep ordinary state transitions beside their state. An action defined by one +store may read and mutate that store only; it must not import another store. +Keep a reusable initial state and provide an explicit reset when the capability +has a reset lifecycle. + +Do not store derived values that can be computed cheaply from current state. +Compute them in a selector or render instead of synchronizing them through an +effect. + +See `references/store-patterns.md` for simple, explicit, scoped, selector, and +reset examples. + +## Expose A Discoverable Namespace + +Expose the store through one capability namespace: + +```ts +export const preferencesStore = { + useStore: usePreferencesStore, +} +``` + +Selecting an ordinary field directly is the default: + +```ts +const theme = preferencesStore.useStore((state) => state.theme) +``` + +Do not generate one wrapper hook per field. Add a named hook only when it +expresses a meaningful derived concept, composes several stores, centralizes +equality behavior, or deliberately hides an unstable internal representation. + +A namespace may also expose feature-level commands and hooks. Treat the +namespace as the public facade of the capability, not as a claim that every +member is a literal Zustand action. + +## Subscribe Narrowly + +- Select the narrowest field or derived primitive the consumer needs. +- Use the repository's shallow-equality helper, such as `useShallow`, when a + selector returns an object, array, or other shallow-comparable collection. +- Avoid `useStore()` without a selector; it subscribes to every change. +- Never mutate objects, arrays, `Map`, or `Set` in place. Return a new reference. +- Inside React, subscribe through the hook. Outside React, use `getState()` or a + public command. +- Use direct `setState()` primarily for tests, stories, framework integration, + and controlled setup. Production mutations go through owned actions or + commands. + +## Keep Orchestration Outside Store Actions + +Feature-level commands and hooks may coordinate several stores, the query +client, storage capabilities, and transports when the workflow clearly belongs +to that feature. Logout is a typical example. + +Prefer a plain command for orchestration that does not require React, then wrap +it in a hook or mutation when the UI needs pending, error, or success state. +Keep navigation, toasts, and other caller-specific UX at the caller unless they +are an invariant of the workflow. + +Derived hooks may subscribe to multiple stores. This is composition, not a +store-to-store dependency. + +Read `references/processes-and-coordination.md` for command, logout, and +cross-store examples. + +## Persist Only An Explicit Contract + +Do not add `persist` by habit. For every persisted store: + +- give the storage key one stable owner; +- whitelist the minimal persisted shape with `partialize`; +- exclude actions, hydration state, in-flight state, caches, runtime handles, + access tokens, and other transient values; +- establish a version before the persisted contract ships; +- validate persisted `unknown` input rather than trusting a cast; +- define migrations for breaking shape changes; +- define a custom merge when nested defaults require more than Zustand's + shallow merge; +- use the repository's storage adapter rather than reading or parsing + Zustand's serialized value elsewhere. + +Choose failure behavior by data class. A disposable cache may reset explicitly. +A decrypt, corruption, or migration failure for credentials, user-created +offline data, or another critical store must surface as an error and must not +silently become “no saved state.” + +Do not rely on `await set(...)` as a portable persistence-completion contract. +When the next operation requires durable completion, put that guarantee behind +a dedicated, tested persistence capability. + +## Model Hydration As A Prerequisite + +Synchronous and asynchronous adapters hydrate differently. Do not add a +hydration gate when no consumer depends on it, and do not assume all persisted +stores hydrate asynchronously. + +For critical asynchronous state, model at least: + +```ts +type HydrationState = + | { status: "hydrating"; error: null } + | { status: "ready"; error: null } + | { status: "error"; error: unknown } +``` + +Use ordered guards when application regions depend on persisted state: + +```tsx +<SecureStoresGuard> + <SessionGuard> + <AccessGuard> + <CriticalDataGuard>{children}</CriticalDataGuard> + </AccessGuard> + </SessionGuard> +</SecureStoresGuard> +``` + +Each guard owns one prerequisite and either renders its loading, error, +blocked, or retry state or passes `children`. Outer guards establish the +invariants assumed by inner guards. Never interpret pre-hydration defaults as +logout, missing credentials, or permission denial. + +Read `references/persistence-and-security.md` before changing persistence, +hydration, authentication storage, encryption, or guards. + +## Keep Secrets Out Of Zustand When Possible + +Secure storage protects data at rest; it does not protect plaintext after a +secret is returned to renderer JavaScript. + +- Keep reactive session metadata in Zustand: status, account identity, + expiration, lock state, and hydration state. +- Keep a short-lived access token in private auth-client memory only when the + renderer must perform authenticated requests. It rarely needs reactivity. +- Keep refresh tokens in an OS-backed native vault or an HttpOnly backend + session where the platform permits. +- Keep private keys non-exportable behind a native or platform capability. + Expose operations such as `sign`, `decrypt`, or `refreshSession`, not + `getSecret` or `getPrivateKey`. +- Never call an adapter `secure` when it is only `localStorage`, ordinary + filesystem storage, or another unprotected backend. +- A storage read returns `null` only when data is genuinely absent. Decryption + or key-access failure must remain an error. + +If architecture forces a raw secret into JavaScript, keep it out of global +reactive state, persistence, devtools, logs, and long-lived strings. Limit it to +the narrowest operation and treat buffer clearing as best effort, not a +security guarantee. + +Follow **native-integration** for capability and RPC boundaries and +**logging** for keeping secrets out of diagnostic records and +**error-handling** for safe incident context. + +## Allow Explicit Process Stores + +A Zustand store may manage a long-running client process when the process: + +- outlives one component or route; +- needs imperative start, cancel, retry, resume, or reattach operations; +- publishes progress to unrelated consumers; +- has a client-owned lifecycle rather than query-owned server state. + +Model explicit status transitions and reject duplicate or stale work. Keep +`AbortController`, streams, sockets, and other runtime handles outside +observable state when consumers do not need them. Never persist those handles; +persist only a deliberate resume record through its owning storage capability. + +When transitions become complex, use an explicit reducer or state machine +inside the capability rather than hiding an implicit state machine in scattered +booleans. + +Read `references/processes-and-coordination.md` before creating a process store. + +## Verify The Boundary + +Before finishing state-management work: + +1. Confirm each value still has one owner. +2. Confirm URL, form, and query state were not duplicated into Zustand. +3. Confirm singleton versus scoped lifetime is intentional. +4. Inspect every React subscription for unnecessary breadth. +5. Exercise reset, logout, remount, and concurrent-operation behavior. +6. For persistence, test migration, corrupted input, missing data, hydration + failure, retry, and durable-write ordering where relevant. +7. For secrets, verify what crosses into renderer memory and whether a narrower + capability can keep it out. + +## Related Skills + +- **api-integration** owns external operations, server state, query identities, + mutations, invalidation, and auth transport behavior. +- **routing** owns path and search state with navigation meaning. +- **forms** owns form values, validation, submission, and field errors. +- **components** owns React-local state, context, composition, and effect + avoidance. +- **file-structure** owns the concrete placement of store files and private + implementation. +- **native-integration** owns OS-backed storage, cryptography, privileged + capabilities, and renderer bridges. +- **logging** owns diagnostic records and the rule that secrets never enter + them. +- **error-handling** owns incident-reporting policy and safe reporting context. diff --git a/.claude/skills/state-management/references/ownership-and-scope.md b/.claude/skills/state-management/references/ownership-and-scope.md new file mode 100644 index 0000000..b56b011 --- /dev/null +++ b/.claude/skills/state-management/references/ownership-and-scope.md @@ -0,0 +1,165 @@ +# State Ownership And Scope + +Use this reference when deciding whether state belongs in Zustand and whether a +store should be a module singleton or a scoped instance. + +## Contents + +- [Ownership Matrix](#ownership-matrix) +- [Do Not Duplicate Query State](#do-not-duplicate-query-state) +- [Singleton Stores](#singleton-stores) +- [Scoped Store Factories](#scoped-store-factories) +- [Context Without Zustand](#context-without-zustand) + +## Ownership Matrix + +| State | Default owner | +| --- | --- | +| Refetchable backend or external data | Query layer | +| Resource identity | Route path params | +| Filters, sorting, pagination, tabs, view options | Route search params | +| Form values, validation, dirty and submit state | Form library | +| One component | React state or reducer | +| One composable subtree | React context | +| Shared client-owned state | Zustand | +| Long-running client process | Process store or dedicated client capability | +| Restart-surviving subset | Explicit persistence boundary | + +These are ownership defaults, not library prohibitions. Change one only when the +value has a different real authority, and document that authority. + +## Do Not Duplicate Query State + +Do not copy query data into a store: + +```ts +const query = useQuery(projectQueryOptions(projectId)) +const setProject = projectStore.useStore((state) => state.setProject) + +useEffect(() => { + if (query.data) setProject(query.data) +}, [query.data, setProject]) +``` + +Read the query directly. Keep only genuine client state in Zustand: + +```ts +const query = useQuery(projectQueryOptions(projectId)) +const selectedPanel = projectWorkspaceStore.useStore( + (state) => state.selectedPanel, +) +``` + +An offline editor, local draft authority, or client process may intentionally +own a snapshot. Make that ownership explicit; do not create it merely to avoid +reading the query cache. + +## Singleton Stores + +A module-level store is appropriate for a true singleton: + +```ts +const usePreferencesStore = create( + combine( + { theme: "system" as ThemePreference }, + (set) => ({ + setTheme: (theme: ThemePreference) => set({ theme }), + }), + ), +) + +export const preferencesStore = { + useStore: usePreferencesStore, +} +``` + +Typical singleton capabilities include application preferences, one active +session, and one application-wide background queue. + +Colocating this file with a page narrows its ownership but does not change its +runtime lifetime. It remains a module singleton. + +## Scoped Store Factories + +Use a factory when each mounted owner needs an independent instance: + +```tsx +import { + createContext, + type PropsWithChildren, + useContext, + useState, +} from "react" +import { createStore, useStore } from "zustand" + +type ProjectWorkspaceStore = { + projectId: string + selectedNodeId: string | null + selectNode: (nodeId: string | null) => void +} + +const createProjectWorkspaceStore = (projectId: string) => + createStore<ProjectWorkspaceStore>()((set) => ({ + projectId, + selectedNodeId: null, + selectNode: (selectedNodeId) => set({ selectedNodeId }), + })) + +type ProjectWorkspaceStoreApi = ReturnType< + typeof createProjectWorkspaceStore +> + +const ProjectWorkspaceStoreContext = + createContext<ProjectWorkspaceStoreApi | null>(null) + +export function ProjectWorkspaceStoreProvider({ + projectId, + children, +}: PropsWithChildren<{ projectId: string }>) { + const [store] = useState(() => createProjectWorkspaceStore(projectId)) + + return ( + <ProjectWorkspaceStoreContext.Provider value={store}> + {children} + </ProjectWorkspaceStoreContext.Provider> + ) +} + +export function useProjectWorkspaceStore<T>( + selector: (state: ProjectWorkspaceStore) => T, +) { + const store = useContext(ProjectWorkspaceStoreContext) + + if (!store) { + throw new Error( + "useProjectWorkspaceStore must be used within ProjectWorkspaceStoreProvider", + ) + } + + return useStore(store, selector) +} +``` + +Create the store once for that provider instance. Do not recreate it on every +render. Key or remount the owner deliberately when its identity changes. + +Use the same pattern for SSR request isolation. Never share a mutable +module-level store between server requests. + +## Context Without Zustand + +Do not add Zustand merely because several compound components share state: + +```tsx +const SelectionContext = createContext<SelectionContextValue | null>(null) +``` + +Prefer plain context when: + +- all consumers live under one obvious provider; +- no imperative access outside React is needed; +- update frequency and provider scope are controlled; +- the state should disappear with that subtree. + +Use a scoped Zustand store when selector-based subscriptions, imperative access, +or a substantial state transition model materially improves that subtree. diff --git a/.claude/skills/state-management/references/persistence-and-security.md b/.claude/skills/state-management/references/persistence-and-security.md new file mode 100644 index 0000000..a31ca44 --- /dev/null +++ b/.claude/skills/state-management/references/persistence-and-security.md @@ -0,0 +1,391 @@ +# Persistence, Hydration, Guards, And Secrets + +Use this reference before implementing or reviewing persisted state, +authentication storage, secure adapters, migrations, hydration, or application +guards. + +## Contents + +- [Persist A Stable Subset](#persist-a-stable-subset) +- [Validate, Migrate, And Merge](#validate-migrate-and-merge) +- [Hydration State](#hydration-state) +- [Guard Composition](#guard-composition) +- [Storage Failure Semantics](#storage-failure-semantics) +- [Secret Placement](#secret-placement) +- [Platform Options](#platform-options) + +## Persist A Stable Subset + +Persist only state that must survive a restart: + +```ts +import { create } from "zustand" +import { + combine, + createJSONStorage, + persist, +} from "zustand/middleware" + +const usePreferencesStore = create( + persist( + combine( + { + theme: "system" as ThemePreference, + density: "comfortable" as DensityPreference, + previewTheme: null as ThemePreference | null, + }, + (set) => ({ + setTheme: (theme: ThemePreference) => set({ theme }), + }), + ), + { + name: "preferences", + version: 1, + storage: createJSONStorage(() => applicationStorage), + partialize: (state) => ({ + theme: state.theme, + density: state.density, + }), + }, + ), +) +``` + +`previewTheme`, actions, hydration state, request state, and runtime handles do +not persist. + +Do not read or parse the serialized Zustand value from another module. If +pre-React bootstrap needs a persisted preference, expose that through the +storage capability that owns the contract. + +## Validate, Migrate, And Merge + +Persisted data is untrusted `unknown` input. Validate it with the repository's +existing schema mechanism: + +```ts +const persistedPreferencesSchema = z.object({ + theme: z.enum(["system", "light", "dark"]), + density: z.enum(["compact", "comfortable"]), +}) + +type PersistedPreferences = z.infer< + typeof persistedPreferencesSchema +> +``` + +Use an explicit persisted return type: + +```ts +partialize: (state): PersistedPreferences => ({ + theme: state.theme, + density: state.density, +}) +``` + +Migration receives `unknown`: + +```ts +migrate: (persistedState, version) => { + if (version === 0) { + const legacy = legacyPreferencesSchema.parse(persistedState) + + return { + theme: legacy.colorMode, + density: "comfortable", + } + } + + return persistedPreferencesSchema.parse(persistedState) +} +``` + +Zustand's default merge is shallow. Merge nested defaults deliberately: + +```ts +merge: (persistedState, currentState) => { + const persisted = persistedWorkspaceSchema.parse(persistedState) + + return { + ...currentState, + workspace: { + ...currentState.workspace, + ...persisted.workspace, + }, + } +} +``` + +Do not add a migration function that silently converts every invalid critical +value into an empty default. Decide whether the data is disposable or whether +failure must block and offer recovery. + +## Hydration State + +Synchronous storage may hydrate during store creation. Asynchronous storage +hydrates later. Add a gate only when consumers must not observe defaults before +hydration. + +For simple noncritical state, a boolean may be sufficient: + +```ts +type HydrationState = { + hasHydrated: boolean +} +``` + +For critical state, preserve loading and error: + +```ts +type HydrationStatus = + | { status: "hydrating"; error: null } + | { status: "ready"; error: null } + | { status: "error"; error: unknown } +``` + +Several secure stores may share a hydration registry: + +```ts +type SecureStoresHydration = { + session: HydrationStatus + accounts: HydrationStatus +} +``` + +Set `skipHydration: true` when native preparation, an unlock step, SSR, or +another prerequisite must happen before the first read: + +```ts +persist(stateCreator, { + name: "session", + storage: createJSONStorage(() => secureSessionStorage), + skipHydration: true, +}) +``` + +Start hydration explicitly when the adapter requires preparation: + +```ts +export async function hydrateSessionStore() { + hydrationStore.useStore.getState().begin("session") + + try { + await sessionVault.prepare() + await sessionStore.useStore.persist.rehydrate() + hydrationStore.useStore.getState().succeed("session") + } catch (error) { + hydrationStore.useStore.getState().fail("session", error) + throw error + } +} +``` + +Keep the original error. Do not invent a normalized error representation solely +for hydration. + +Do not assume `await set(...)` is a portable durable-write contract. If a +workflow must wait until encrypted persistence completes before locking, +navigating, or making another request, expose and test that guarantee through +the persistence capability. + +## Guard Composition + +Compose one prerequisite per guard: + +```tsx +function Application({ children }: PropsWithChildren) { + return ( + <SecureStoresGuard> + <SessionGuard> + <AccessGuard> + <CriticalDataGuard> + <ApplicationLayout>{children}</ApplicationLayout> + </CriticalDataGuard> + </AccessGuard> + </SessionGuard> + </SecureStoresGuard> + ) +} +``` + +The order is the contract: + +1. Secure persistence is readable. +2. Session presence is known. +3. Authorization is settled. +4. Critical application data is available. +5. The application layout and its consumers may mount. + +Each guard selects only its prerequisite and owns its fallback: + +```tsx +function SecureStoresGuard({ children }: PropsWithChildren) { + const hydration = hydrationStore.useStore((state) => state.session) + + if (hydration.status === "hydrating") { + return <FullScreenSpinner /> + } + + if (hydration.status === "error") { + return ( + <SecureStorageError + error={hydration.error} + onRetry={hydrateSessionStore} + /> + ) + } + + return children +} +``` + +Do not let a guard infer logout, denial, or missing data from a value whose +prerequisite has not passed. A hydration error must not automatically clear the +session. + +Hydration may start in the application entrypoint so no synchronization effect +is needed: + +```ts +void hydrateSecureStores().catch(() => { + // The hydration registry retains the original error for its guard. +}) + +createRoot(rootElement).render(<Application />) +``` + +An established query layer is also acceptable for async bootstrap when its +pending, retry, and error lifecycle fits the operation. + +## Storage Failure Semantics + +Return `null` only for genuine absence: + +```ts +async function getItem(name: string) { + const encryptedValue = backingStorage.getItem(name) + + if (encryptedValue === null) return null + + return nativeSecurity.decrypt(encryptedValue) +} +``` + +Do not swallow decryption failure: + +```ts +// Wrong: this changes "unreadable" into "missing". +async function getItem(name: string) { + try { + return await decrypt(backingStorage.getItem(name)) + } catch { + return null + } +} +``` + +The false “missing” result can hydrate empty defaults and later overwrite +recoverable encrypted data. + +## Secret Placement + +Secure storage protects data at rest. Once plaintext is returned to renderer +JavaScript, it may be reachable through application code, devtools, heap +snapshots, injected code, logs, or accidental persistence. + +Prefer this separation: + +```text +Zustand + session status + account identity + expiration + lock and hydration state + +Private auth-client memory + short-lived access token, only if renderer requests require it + +Native or backend vault + refresh token + private keys + long-lived credentials +``` + +An access token rarely needs reactivity. Prefer a private provider when the +renderer must hold it: + +```ts +let accessToken: string | null = null + +export const accessTokenProvider = { + get: () => accessToken, + set: (value: string | null) => { + accessToken = value + }, + clear: () => { + accessToken = null + }, +} +``` + +This reduces accidental exposure but does not protect against full renderer +compromise. + +Do not export raw private keys. Keep them non-exportable and expose operations: + +```ts +type SigningCapability = { + createKey: () => Promise<{ + keyId: string + publicKey: string + }> + sign: (input: { + keyId: string + payload: Uint8Array + }) => Promise<Uint8Array> + deleteKey: (keyId: string) => Promise<void> +} +``` + +Zustand may keep `keyId`, `publicKey`, and readiness state. The private key stays +behind the capability. + +When a raw key must temporarily enter JavaScript, keep it in the narrowest +lexical scope, prefer mutable byte buffers over immutable strings, never place +it in Zustand or persistence, and clear buffers as best effort. JavaScript +runtimes may retain copies, so do not claim guaranteed zeroization. + +## Platform Options + +Inspect the repository's threat model, existing native capabilities, and the +current official platform documentation before choosing an adapter. Security +semantics and fallbacks vary by operating system and runtime version. + +Choose the strongest boundary the platform supports: + +- Browser with backend: prefer a Backend-for-Frontend or HttpOnly, Secure, + SameSite session so tokens never enter application JavaScript. +- Browser without backend mediation: keep short-lived access tokens in memory; + never use `localStorage` or `sessionStorage` for credentials; require refresh + token rotation or sender constraint when refresh tokens are issued. +- Web cryptography: prefer non-extractable `CryptoKey` handles for local key + operations. They prevent raw export but cannot prevent compromised same-origin + code from invoking an allowed operation. +- Electron or another desktop shell: keep secrets and cryptographic operations + in the privileged native/main capability. Use OS Keychain, DPAPI, Secret + Service, or a framework wrapper such as Electron `safeStorage`; verify that + the selected Linux backend is not a plaintext fallback. +- Apple platforms: prefer Keychain and non-exportable key operations, using + Secure Enclave where the required algorithm and lifecycle permit it. +- Android: prefer Android Keystore and hardware-backed, non-exportable keys when + available. +- React Native or Expo: use the established Keychain/Keystore-backed secure + storage adapter for small tokens and secrets; prefer native non-exportable key + APIs over storing raw PEM keys. + +For larger encrypted data, store ciphertext in ordinary application storage and +keep only its data-encryption or wrapping key in the OS vault. + +Expose narrow native methods such as `sign`, `refreshSession`, and +`clearSession`. Never expose a generic `getSecret` or an unrestricted IPC +surface. diff --git a/.claude/skills/state-management/references/processes-and-coordination.md b/.claude/skills/state-management/references/processes-and-coordination.md new file mode 100644 index 0000000..eec5867 --- /dev/null +++ b/.claude/skills/state-management/references/processes-and-coordination.md @@ -0,0 +1,233 @@ +# Process Stores And Coordination + +Use this reference for long-running client processes, cross-store workflows, +logout, and feature-level commands. + +## Contents + +- [Process Store](#process-store) +- [Runtime Handles](#runtime-handles) +- [Stale Work And Reattachment](#stale-work-and-reattachment) +- [Cross-Store Commands](#cross-store-commands) +- [Hook Adapters](#hook-adapters) + +## Process Store + +Use a process store when work must continue independently of one component: + +```ts +type ExportProcessState = { + status: "idle" | "running" | "completed" | "failed" + progress: number + error: unknown +} + +type ExportProcessActions = { + start: (input: ExportInput) => Promise<void> + cancel: () => void + reset: () => void +} + +type ExportProcessStore = ExportProcessState & ExportProcessActions + +const initialState: ExportProcessState = { + status: "idle", + progress: 0, + error: null, +} + +let activeController: AbortController | null = null + +const useExportProcessStore = create<ExportProcessStore>()((set, get) => ({ + ...initialState, + + start: async (input) => { + if (get().status === "running") return + + const controller = new AbortController() + activeController = controller + set({ status: "running", progress: 0, error: null }) + + try { + await exportClient.run(input, { + signal: controller.signal, + onProgress: (progress) => set({ progress }), + }) + + if (activeController !== controller || controller.signal.aborted) return + + set({ status: "completed", progress: 1 }) + } catch (error) { + if (activeController !== controller || controller.signal.aborted) return + + set({ status: "failed", error }) + } finally { + if (activeController === controller) activeController = null + } + }, + + cancel: () => { + activeController?.abort() + activeController = null + set(initialState) + }, + + reset: () => { + if (get().status === "running") return + set(initialState) + }, +})) + +export const exportProcessStore = { + useStore: useExportProcessStore, +} +``` + +The identity check prevents a late completion from an older operation from +overwriting a newer run. + +## Runtime Handles + +Keep runtime handles outside observable state unless consumers genuinely need +them: + +- `AbortController`; +- sockets and streams; +- timers; +- imperative clients; +- native resource handles; +- promises and async iterators. + +They are implementation details, are usually non-serializable, and must never +enter persisted state. + +If the process must survive a renderer restart, persist a deliberate resume +record through the process's storage capability: + +```ts +type ResumeRecord = { + runId: string + resourceId: string + startedAt: string +} +``` + +Do not persist the controller, stream, client, or accumulated implementation +object graph. + +## Stale Work And Reattachment + +Long-running stores must define: + +- what happens when `start` is called twice; +- how cancellation settles state; +- whether a finished result may update a newer run; +- whether restart means reattach, resume, reconcile, or fail; +- who owns any persisted resume record; +- when retry is allowed. + +Prefer explicit status or transition models over independent booleans: + +```ts +type ProcessStatus = + | { type: "idle" } + | { type: "running"; runId: string } + | { type: "resumable"; runId: string } + | { type: "completed" } + | { type: "failed"; error: unknown } +``` + +Use a reducer or state machine when valid transitions are no longer obvious +from a small store. + +## Cross-Store Commands + +A store action owns only its own state: + +```ts +const useSessionStore = create( + combine(sessionInitialState, (set) => ({ + clear: () => set(sessionInitialState), + })), +) +``` + +Do not import peer stores into that state creator. Coordinate them in a +feature-level command: + +```ts +export async function logout() { + await queryClient.cancelQueries() + queryClient.clear() + + sessionStore.useStore.getState().clear() + userStore.useStore.getState().clear() + notificationsStore.useStore.getState().clear() + + await sessionVault.clearSession() +} +``` + +The explicit list is intentional. Avoid a generic reset registry until several +real workflows prove that it improves ownership rather than hiding it. + +The command may live in the session capability and appear in its namespace: + +```ts +export const sessionStore = { + useStore: useSessionStore, + logout, +} +``` + +This is allowed because the namespace is a feature facade. `logout` is not +pretending to be an action inside the Zustand state creator. + +If several stores must always change atomically, reconsider their boundary. +They may be slices of one capability rather than independent stores. + +## Hook Adapters + +Wrap a plain command when React needs mutation lifecycle: + +```ts +function useLogoutMutation() { + return useMutation({ + mutationKey: ["session", "logout"], + mutationFn: logout, + }) +} + +export const sessionStore = { + useStore: useSessionStore, + logout, + useLogoutMutation, +} +``` + +Keep caller-specific navigation and feedback at the caller: + +```ts +const logoutMutation = sessionStore.useLogoutMutation() + +const handleLogout = async () => { + await logoutMutation.mutateAsync() + await router.invalidate() +} +``` + +A feature-level hook may compose multiple stores reactively: + +```ts +function useCanStartExport() { + const projectId = projectStore.useStore((state) => state.selectedId) + const processStatus = exportProcessStore.useStore( + (state) => state.status, + ) + + return projectId !== null && processStatus === "idle" +} +``` + +This is allowed. The state creators remain independent; the hook owns the +composition. diff --git a/.claude/skills/state-management/references/store-patterns.md b/.claude/skills/state-management/references/store-patterns.md new file mode 100644 index 0000000..15cc8f1 --- /dev/null +++ b/.claude/skills/state-management/references/store-patterns.md @@ -0,0 +1,211 @@ +# Store Patterns + +Use this reference for ordinary store definitions, namespaces, selectors, and +reset behavior. + +## Contents + +- [Simple Inferred Store](#simple-inferred-store) +- [Explicit Store Contract](#explicit-store-contract) +- [Namespace And Selectors](#namespace-and-selectors) +- [Immutable Updates](#immutable-updates) +- [Reset](#reset) + +## Simple Inferred Store + +Use `combine` when inference keeps a small store clearer: + +```ts +import { create } from "zustand" +import { combine } from "zustand/middleware" + +const initialState = { + selectedId: null as string | null, + expandedIds: [] as string[], +} + +const useNavigationStore = create( + combine(initialState, (set) => ({ + select: (selectedId: string | null) => set({ selectedId }), + expand: (id: string) => + set((state) => ({ + expandedIds: state.expandedIds.includes(id) + ? state.expandedIds + : [...state.expandedIds, id], + })), + reset: () => set({ ...initialState, expandedIds: [] }), + })), +) + +export const navigationStore = { + useStore: useNavigationStore, +} +``` + +Do not use state replacement with an inferred `combine` store. Replacing only +the initial-state shape can remove its actions. + +## Explicit Store Contract + +Use an explicit contract when it documents a factory, async lifecycle, public +package surface, or complex store better than inference: + +```ts +type UploadState = { + status: "idle" | "uploading" | "completed" | "failed" + progress: number + error: unknown +} + +type UploadActions = { + setProgress: (progress: number) => void + fail: (error: unknown) => void + complete: () => void + reset: () => void +} + +type UploadStore = UploadState & UploadActions + +const initialUploadState: UploadState = { + status: "idle", + progress: 0, + error: null, +} + +const useUploadStore = create<UploadStore>()((set) => ({ + ...initialUploadState, + setProgress: (progress) => set({ status: "uploading", progress }), + fail: (error) => set({ status: "failed", error }), + complete: () => set({ status: "completed", progress: 1 }), + reset: () => set(initialUploadState), +})) +``` + +Keep useful types beside the capability. Do not move them into a generic +`types.ts` file. + +## Namespace And Selectors + +Expose one discoverable namespace: + +```ts +const useStore = create(/* ... */) + +export const workspaceStore = { + useStore, +} +``` + +Select ordinary fields directly: + +```tsx +const selectedId = workspaceStore.useStore((state) => state.selectedId) +const select = workspaceStore.useStore((state) => state.select) +``` + +Select several fields with shallow equality: + +```tsx +const { status, progress } = uploadStore.useStore( + useShallow((state) => ({ + status: state.status, + progress: state.progress, + })), +) +``` + +Do not subscribe to the whole store: + +```tsx +// Avoid: rerenders for every store change. +const store = uploadStore.useStore() +``` + +Create a named hook when it owns meaningful logic: + +```ts +const useCanCancelUpload = () => + uploadStore.useStore( + (state) => state.status === "uploading" && state.progress < 1, + ) +``` + +A named hook may compose stores without creating a dependency between their +state creators: + +```ts +const useCanOpenWorkspace = () => { + const isAuthenticated = sessionStore.useStore( + (state) => state.status === "authenticated", + ) + const projectId = projectStore.useStore((state) => state.selectedId) + + return isAuthenticated && projectId !== null +} +``` + +Outside React, prefer a public command or use `getState()`: + +```ts +workspaceStore.useStore.getState().select(projectId) +``` + +## Immutable Updates + +Return new references: + +```ts +set((state) => ({ + items: [...state.items, item], +})) +``` + +Create new `Map` and `Set` instances: + +```ts +set((state) => ({ + selectedIds: new Set(state.selectedIds).add(id), +})) +``` + +Never mutate and return the existing reference: + +```ts +// Wrong: subscribers may not observe a change. +set((state) => { + state.selectedIds.add(id) + return { selectedIds: state.selectedIds } +}) +``` + +## Reset + +Model reset as an owned action: + +```ts +const createInitialState = (): NavigationState => ({ + selectedId: null, + expandedIds: new Set(), +}) + +const useNavigationStore = create<NavigationState & NavigationActions>()( + (set) => ({ + ...createInitialState(), + reset: () => set(createInitialState()), + }), +) +``` + +Use a factory when initial state contains mutable collections so reset never +reuses a previously mutated reference. + +Direct `setState()` is acceptable for tests and stories: + +```ts +beforeEach(() => { + useNavigationStore.setState(createInitialState()) +}) +``` + +Do not replace state with a state-only object because that removes actions. +Reset through the owned action when it represents the public test contract. diff --git a/.claude/skills/user-feedback/SKILL.md b/.claude/skills/user-feedback/SKILL.md new file mode 100644 index 0000000..e353abd --- /dev/null +++ b/.claude/skills/user-feedback/SKILL.md @@ -0,0 +1,217 @@ +--- +name: user-feedback +description: Use whenever work adds, changes, reviews, or debugs user-facing outcome feedback such as error, success, warning, or informational messages; toasts, snackbars, alerts, banners, dialogs, inline errors, empty or failed regions, form submission feedback, retry actions, crash or initialization fallbacks, domain-outcome presentation, or mapping technical failures to localized product copy. Trigger even when the request only mentions showError, showSuccess, notifications, server messages, error.message, mutation onError or onSuccess UX, or choosing how and where to tell the user what happened. +--- + +# User Feedback + +Present one clear outcome at the boundary that owns the user interaction. Choose +the surface from the duration, scope, severity, and recovery needs of the +message; do not route every outcome through a toast. + +Feedback is presentation. It does not decide whether a failure is logged, +reported as an incident, retried, or converted. + +## Inspect The Existing Feedback System + +Before adding feedback: + +1. Find the repository's UI primitives and existing toast, snackbar, banner, + alert, dialog, and error-region components. +2. Find any feedback facade or event bus and the runtime composition root that + renders it. +3. Check how localization, accessibility announcements, durations, actions, and + duplicate messages are handled. +4. Trace whether the same interaction already presents inline state or + navigation that makes another message redundant. +5. Find the technical error owner in **error-handling** before adding failure + feedback. + +Reuse the established presentation path. Do not introduce another toast library +or global event bus for one feature. + +## Choose The Smallest Correct Surface + +Use the surface that matches the problem: + +| Surface | Prefer for | +| --- | --- | +| Field message | Validation or server failure tied to one form field | +| Inline region | A failed or unavailable section with local retry | +| Toast or snackbar | A transient action outcome that does not block work | +| Banner | Persistent page- or application-level degraded state | +| Dialog or native alert | A blocking decision or consequence requiring acknowledgement | +| Route or subtree fallback | A render failure that invalidates that region | +| Application fallback | Initialization or unrecoverable root failure | + +Do not use a transient toast for a persistent failure. Do not replace an entire +page when only one region failed. Do not show both an inline message and a toast +for the same outcome unless they serve demonstrably different purposes. + +Read `references/presentation-patterns.md` for adaptable mapping, inline, +mutation, and fallback examples. + +## Use Product Copy, Not Technical Messages + +Do not show `error.message` to the user by default. It may be technical, +unstable, unlocalized, unsafe, or meaningless outside developer diagnostics. + +Prefer: + +1. a localized message selected by a known typed outcome; +2. a user-safe message explicitly guaranteed by the external contract; +3. a localized operation-specific fallback. + +Do not string-match a technical message to choose feedback when a status, code, +or discriminant exists. Do not expose stack traces, request bodies, provider +responses, identifiers the user cannot act on, or secret material. + +## Map Meaningful Outcomes Explicitly + +Keep feedback mapping close to the feature or shared domain boundary that owns +the meaning: + +```ts +function getProjectUpdateFeedback(error: unknown): Feedback { + if (isApprovalRequired(error)) { + return { + kind: "warning", + message: translate("Approval is required before this change can apply."), + } + } + + return { + kind: "error", + message: translate("Unable to update the project."), + } +} +``` + +Promote a mapper to shared `common` code only when genuinely different +consumers need the same meaning. Follow **file-structure** for its placement. + +Keep mapping pure: it selects presentation data. It must not report, log, +navigate, mutate state, or emit the feedback itself. + +## Keep Feedback And Reporting Independent + +Avoid APIs such as: + +```ts +showError(error, { report: true }) +``` + +A presentation helper cannot know whether a query cache, global listener, or +caller already reported the failure. Hidden reporting creates duplicates and +makes UI code control observability policy. + +When one interaction boundary owns both decisions, keep them visible: + +```ts +catch (error) { + reportError(error, { + module: "projects", + context: { projectId }, + }) + + showError({ + message: translate("Unable to update the project."), + }) +} +``` + +If a central observer already reports the failure, call only the feedback path +locally. + +## Treat Success As Optional Feedback + +Do not emit a success toast after every completed operation. + +Skip it when success is already obvious because the UI navigated, closed, +updated the resource, or displayed the new state. Use explicit success +feedback when completion would otherwise be ambiguous, delayed, performed in +the background, or especially consequential. + +Success copy should state the completed result, not merely `"Success"`. + +## Keep Validation Near The Field + +Client validation and server field errors belong beside their fields. Do not +replace them with a global toast. + +Use a form-level or toast failure only for a submission problem that cannot be +assigned to one field. Present one path: after mapping a server error to a +field, do not also show the same failure as a general error. + +Follow **forms** for schemas, React Hook Form, `setError`, submission state, and +field composition. + +## Make Recovery Actionable + +When the user can recover, present the relevant action: + +- retry the failed region or operation; +- reload or restart after an unrecoverable boundary; +- return to a stable route; +- reauthenticate when the session is no longer valid; +- open the resource created by an asynchronous or approval flow; +- dismiss a transient message. + +Do not offer retry when the operation is unsafe to repeat or when +**error-handling** has not established a retry policy. + +Preserve the user's entered data and surrounding usable state whenever +possible. + +## Handle Query And Mutation Feedback Locally + +Central Query or Mutation cache callbacks may own technical reporting, but they +do not know whether a toast, inline state, or no message is appropriate. + +- Present initial query failure in the failed region. +- Preserve cached data during a background refetch failure when possible. +- Present mutation feedback at the interaction boundary. +- Avoid global automatic error toasts for all queries and mutations. +- Keep cache invalidation and canonical lifecycle behavior in + **api-integration**. + +## Keep Feedback Accessible And Localized + +- Use the existing localization system for product copy. +- Ensure dynamic updates are announced through the established accessible + toast, live-region, alert, or native mechanism. +- Keep actionable controls keyboard and screen-reader reachable. +- Do not rely on color or an icon alone to communicate severity. +- Give persistent messages a visible dismissal or recovery path when + appropriate. +- Avoid durations too short for the message or action. + +## Verify The Result + +Before finishing: + +- one outcome produces one presentation; +- the surface matches the scope and persistence of the outcome; +- technical `error.message` is not exposed accidentally; +- copy is localized and actionable; +- known outcomes use typed fields rather than string matching; +- success feedback is not redundant with visible state; +- field errors remain attached to fields; +- feedback does not secretly log or report; +- retry is safe and owned by the technical error flow; +- accessibility and dismissal behavior match existing primitives. + +## Related Skills + +- Catch ownership, reporting, retry, cancellation, and boundaries → + **error-handling**. +- Form validation, submission, and server field errors → **forms**. +- UI primitives, composition, async-region rendering, and fallback components + → **components**. +- Query and mutation lifecycle, typed transport errors, and invalidation → + **api-integration**. +- Translation keys, interpolation, plurals, and locale-reactive copy → + **localization**. +- Placement and promotion of shared feedback modules → **file-structure**. +- Feedback notifications arriving from a native host → + **native-integration**. diff --git a/.claude/skills/user-feedback/agents/openai.yaml b/.claude/skills/user-feedback/agents/openai.yaml new file mode 100644 index 0000000..2e41ee4 --- /dev/null +++ b/.claude/skills/user-feedback/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "User Feedback" + short_description: "Safe and contextual outcome feedback" + default_prompt: "Use $user-feedback to design or review user-facing success, warning, error, and recovery feedback." diff --git a/.claude/skills/user-feedback/references/presentation-patterns.md b/.claude/skills/user-feedback/references/presentation-patterns.md new file mode 100644 index 0000000..1fab98d --- /dev/null +++ b/.claude/skills/user-feedback/references/presentation-patterns.md @@ -0,0 +1,173 @@ +# User Feedback Presentation Patterns + +Adapt these examples to the repository's existing primitives, localization, and +error contracts. The names are illustrative. + +## Contents + +- [Pure Feedback Mapping](#pure-feedback-mapping) +- [Mutation Feedback Without Duplicate Reporting](#mutation-feedback-without-duplicate-reporting) +- [Inline Query Failure](#inline-query-failure) +- [Server Field Error](#server-field-error) +- [Blocking And Root Failures](#blocking-and-root-failures) + +## Pure Feedback Mapping + +Map a known outcome without causing side effects: + +```ts +type Feedback = + | { + kind: "error" + message: string + } + | { + kind: "warning" + message: string + action?: { + label: string + href: string + } + } + +function getSaveFeedback(error: unknown): Feedback { + if (isApprovalRequired(error)) { + return { + kind: "warning", + message: translate("Your change was submitted for approval."), + action: { + label: translate("View request"), + href: `/requests/${error.requestId}`, + }, + } + } + + return { + kind: "error", + message: translate("Unable to save your changes."), + } +} +``` + +The renderer or feedback facade decides how to display the returned structure. +The mapper does not report the error or navigate. + +## Mutation Feedback Without Duplicate Reporting + +When the Query mutation cache already owns technical reporting: + +```ts +try { + await updateProject.mutateAsync({ + projectId, + name, + }) + + closeDialog() +} catch (error) { + const feedback = getSaveFeedback(error) + showFeedback(feedback) +} +``` + +There is no success toast because closing the dialog and updating the project +already make completion visible. + +When no central reporter observes this imperative operation: + +```ts +try { + await exportReport(reportId) + showSuccess(translate("Report exported.")) +} catch (error) { + reportError(error, { + module: "report-export", + context: { reportId }, + }) + + showError({ + message: translate("Unable to export the report."), + }) +} +``` + +## Inline Query Failure + +Keep a local failure inside the region it invalidates: + +```tsx +const content = (() => { + if (query.isPending) { + return <UiSkeleton /> + } + + if (query.isError && query.data === undefined) { + return ( + <UiAlert variant="error"> + <UiAlertTitle>{translate("Projects could not be loaded.")}</UiAlertTitle> + <UiAlertAction onClick={() => void query.refetch()}> + {translate("Try again")} + </UiAlertAction> + </UiAlert> + ) + } + + if (query.data.length === 0) { + return <ProjectsEmptyState /> + } + + return <ProjectsList projects={query.data} /> +})() +``` + +A background refetch failure with existing `query.data` should normally keep +the list visible. Add a subtle stale or retry indication only when it helps the +user. + +## Server Field Error + +Attach a known field failure to the field and stop: + +```ts +catch (error) { + if (isNameTakenError(error)) { + setError("name", { + message: translate("This name is already in use."), + }) + return + } + + showError({ + message: translate("Unable to save the project."), + }) +} +``` + +Do not also show a general toast after setting the field error. + +## Blocking And Root Failures + +Use a dialog or native alert when the user must acknowledge a consequence or +choose an action before continuing. + +Use a route, subtree, or application fallback when rendering or initialization +cannot continue. Keep the fallback stable and offer the narrowest valid +recovery action: + +```tsx +function InitializationFallback() { + return ( + <UiResult> + <UiResultTitle>{translate("The application could not start.")}</UiResultTitle> + <UiResultDescription> + {translate("Restart the application and try again.")} + </UiResultDescription> + <UiButton onClick={restartApplication}> + {translate("Restart")} + </UiButton> + </UiResult> + ) +} +``` + +Do not accompany the fallback with a duplicate toast. From 24ce9786752e7348d226f888890cb480092204e4 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 12:04:15 +0300 Subject: [PATCH 069/124] fix(ci): pin the toolchain the workspace actually declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm build action pinned rustc 1.85.0 and failed on the first CI run this repository has ever had: `ar_archive_writer@0.5.2`, reached through wasm-pack, requires 1.88.0. `crates/simplex` declares 1.91.0 as its own minimum, so 1.85.0 was never right — it built locally only because the local toolchain is 1.94.1, which is exactly the divergence pinning exists to prevent. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .github/actions/build-smplx-wasm/action.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-smplx-wasm/action.yml b/.github/actions/build-smplx-wasm/action.yml index e406159..782c946 100644 --- a/.github/actions/build-smplx-wasm/action.yml +++ b/.github/actions/build-smplx-wasm/action.yml @@ -27,11 +27,15 @@ runs: path: smplx/crates/wasm/pkg key: smplx-wasm-${{ runner.os }}-${{ steps.smplx.outputs.sha }} - - name: Install Rust toolchain (1.85.0 + wasm32) + # 1.91.0 because `crates/simplex` declares it as the workspace's minimum, and the + # build tree demands it independently: `ar_archive_writer` reached through wasm-pack + # requires 1.88.0. An earlier pin of 1.85.0 built locally on a newer toolchain and + # failed here on the runner's, which is the whole reason to pin rather than inherit. + - name: Install Rust toolchain (1.91.0 + wasm32) if: steps.pkg-cache.outputs.cache-hit != 'true' uses: dtolnay/rust-toolchain@master with: - toolchain: "1.85.0" + toolchain: "1.91.0" targets: wasm32-unknown-unknown - name: Cache cargo registry + build From 1e89b81d5119a018696d441c62eb43f9dada3095 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 12:11:59 +0300 Subject: [PATCH 070/124] chore(workflow): wfctl upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five owned files, on their own commit as the installer asks. Two carry rules that bear on work already in flight: `project-work-lifecycle` reorders step 11 — completion approval comes before promotion, because a curated page cites the change as its authority and that citation resolves only once the change is receipt-ready. Drafting earlier is free; writing into `knowledge/` earlier leaves the corpus invalid so progress can look further along than it is. That is what the drafts under `artifacts/promotion-draft/` were moved out for, now stated rather than inferred. `maintainer-review` adds "re-establish the subject before asking about it": a claim is the unit of record and not the unit of a question, and a packet must be re-checked against current source rather than repeating a days-old record. The rest is the AGENTS.md managed block, the align-project-knowledge skill, and the lock and state files that follow them. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../skills/align-project-knowledge/SKILL.md | 26 ++++++++++ .claude/rules/maintainer-review.md | 49 +++++++++++++++++++ .claude/rules/project-work-lifecycle.md | 9 +++- .../skills/align-project-knowledge/SKILL.md | 26 ++++++++++ .workflow/rules/maintainer-review.md | 49 +++++++++++++++++++ .workflow/rules/project-work-lifecycle.md | 9 +++- .workflow/state.json | 10 ++-- AGENTS.md | 14 ++++++ skills-lock.json | 2 +- 9 files changed, 184 insertions(+), 10 deletions(-) diff --git a/.agents/skills/align-project-knowledge/SKILL.md b/.agents/skills/align-project-knowledge/SKILL.md index 7d3cd0c..79c2e2a 100644 --- a/.agents/skills/align-project-knowledge/SKILL.md +++ b/.agents/skills/align-project-knowledge/SKILL.md @@ -82,6 +82,32 @@ Recommend a reconstruction when the gap is material and say what it would establish, then proceed if the maintainer declines. It is a recommendation, and it never becomes a precondition for doing the work. +## Recorded drift is work nobody has claimed + +`realization.alignment: drifted` on a curated page means the project accepted an +intent its implementation does not deliver. That row is a faithful record and +nothing more: reconstruction never edits source, so the gap it names outlives +the case that found it. `wfctl brief` reports `corpus.intent-delivery-drift` +with the pages by name, and a body of debt that only ever appears there is the +same as no record at all. + +Read those pages whenever work touches their Area, and treat each one as a +candidate the current task either resolves, widens, or leaves untouched. Say +which, in the alignment record, so the next reader knows the drift was seen +rather than missed. + +Drift becomes work through the ordinary route and never by direct promotion. +Group the drifted pages by the outcome that would close them — several rows are +usually one initiative — and put that outcome to the maintainer as one decision +with three honest answers: shape it now, accept the gap and record the intent as +superseded so the drift disappears truthfully, or defer it with a reason. Only +the first creates a bundle, through `shape-project-direction` when the route is +foggy and `specify-project-change` when it is not. + +Never resolve drift by editing the page to match the code. That erases an +accepted intent to make a check pass, and the record of what the project meant +is the only thing that made the gap visible. + ## Conflicts - `raw/` is neither evidence nor current truth. It is an untrusted clue source diff --git a/.claude/rules/maintainer-review.md b/.claude/rules/maintainer-review.md index 87b9540..2a6c540 100644 --- a/.claude/rules/maintainer-review.md +++ b/.claude/rules/maintainer-review.md @@ -50,6 +50,38 @@ Detail is not the problem and must not be dropped; its position is. Depth that arrives before the question buries it, and the same depth below a stated question supports it. +## Re-establish the subject before asking about it + +A claim is the unit of record. It is not the unit of a question. Recorded +atomically, a claim keeps only what distinguishes it from its neighbors, which +is exactly what a reader who was not there cannot reconstruct: a packet built +straight from one carries a difference with no thing attached to it. "This is +one colour on one side and another on the other side — was that intended?" is a +complete atomic claim and an unanswerable question. + +Before the packet is written, re-establish four things and put them above the +question: + +- **What it is**, named as the product names it, and where a person using or + operating the product meets it. +- **Where it came from** — which sources say this, and when they said it. +- **What turns on it** — the flow it sits in and what changes downstream if the + answer goes either way. +- **What is true right now.** Go back to the pinned source and the current + implementation and check. The candidate was extracted at reading time, before + later findings, and other candidates recorded since may already contradict or + settle it. A packet that repeats a days-old record without re-reading is + asking the maintainer to adjudicate the agent's memory. + +Ask about the subject and list its atomic claims underneath, rather than asking +about each claim. Several claims about one capability are one question; one +claim touching several capabilities is still one question, about the one thing +that has to be decided. + +If the subject cannot be described without file paths or symbol names, that is a +finding to record — the capability has no product-level description yet — and +not a licence to send the identifiers instead. + Translate rather than cite. A term that exists only in the implementation gets replaced by what it does for the product, with the original in parentheses at most once. If a decision genuinely cannot be stated without engineering @@ -75,6 +107,14 @@ and confirming intent that was are different questions, and asking the harder one when the easier one applies is how a body of accepted work becomes an unanswered queue. +Put the framing decision before implementation, not before closure. `wfctl work +issue claim` refuses a delivery issue whose framing is unapproved, which is the +moment to present it: the bundle has been read, nothing is in flight, and the +maintainer's absence blocks nothing yet. Discovering the same gate at completion +parks finished work behind a decision that could have been made on day one. +Approving edits the change record, so refresh its checkpoint and re-read it +before claiming. + For significant work, record framing and completion decisions with `wfctl work approve <id> --stage framing|completion --by human:<maintainer-id>`. That command needs an interactive terminal, or an out-of-band `--token` matching @@ -89,6 +129,15 @@ changes materially. Approving edits the change record, so re-read it, refresh its review receipt, and refresh the checkpoint afterwards. +A checkpoint blocker is the one sentence written for the maintainer rather than +for the next agent, and the only one that reaches them across a session +boundary. That makes a stale blocker worse than a missing one: a missing blocker +makes the next session look, and a stale blocker makes it act — reopening a +decision already made, or recommending an option since proved impossible. When +an answer, a discovery, or a change removes what a record was waiting for, +rewrite its blocker in the same turn. Nothing validates blocker text against the +record it hangs on; only the person changing the record can keep it true. + After every material maintainer turn, update the spec's mutable current state and append the decision/discussion ledger before continuing. Preserve proposed, approved, rejected, deferred, and superseded outcomes without copying the diff --git a/.claude/rules/project-work-lifecycle.md b/.claude/rules/project-work-lifecycle.md index 5420db9..fececff 100644 --- a/.claude/rules/project-work-lifecycle.md +++ b/.claude/rules/project-work-lifecycle.md @@ -27,8 +27,13 @@ repository owns significant-work bundles and current project knowledge. 10. Preserve implementation in the exact clean Git commit only with normal maintainer authorization. 11. Run `verify-project-work` across the whole bundle and every bound source - revision, promote verified durable truth, obtain completion approval, and - close honestly. + revision, obtain completion approval, then promote verified durable truth + and close honestly. That order is not a preference: a curated page cites the + change as its authority, and that citation resolves only once the change is + receipt-ready, which requires the recorded completion approval. Drafting the + pages earlier is useful and costs nothing; writing them into `knowledge/` + earlier leaves the corpus invalid so that progress can look further along + than it is. On a clean session or resume, run `wfctl work context --stage resume` without an ID. It may auto-select only one bound active record; multiple records require a diff --git a/.claude/skills/align-project-knowledge/SKILL.md b/.claude/skills/align-project-knowledge/SKILL.md index 7d3cd0c..79c2e2a 100644 --- a/.claude/skills/align-project-knowledge/SKILL.md +++ b/.claude/skills/align-project-knowledge/SKILL.md @@ -82,6 +82,32 @@ Recommend a reconstruction when the gap is material and say what it would establish, then proceed if the maintainer declines. It is a recommendation, and it never becomes a precondition for doing the work. +## Recorded drift is work nobody has claimed + +`realization.alignment: drifted` on a curated page means the project accepted an +intent its implementation does not deliver. That row is a faithful record and +nothing more: reconstruction never edits source, so the gap it names outlives +the case that found it. `wfctl brief` reports `corpus.intent-delivery-drift` +with the pages by name, and a body of debt that only ever appears there is the +same as no record at all. + +Read those pages whenever work touches their Area, and treat each one as a +candidate the current task either resolves, widens, or leaves untouched. Say +which, in the alignment record, so the next reader knows the drift was seen +rather than missed. + +Drift becomes work through the ordinary route and never by direct promotion. +Group the drifted pages by the outcome that would close them — several rows are +usually one initiative — and put that outcome to the maintainer as one decision +with three honest answers: shape it now, accept the gap and record the intent as +superseded so the drift disappears truthfully, or defer it with a reason. Only +the first creates a bundle, through `shape-project-direction` when the route is +foggy and `specify-project-change` when it is not. + +Never resolve drift by editing the page to match the code. That erases an +accepted intent to make a check pass, and the record of what the project meant +is the only thing that made the gap visible. + ## Conflicts - `raw/` is neither evidence nor current truth. It is an untrusted clue source diff --git a/.workflow/rules/maintainer-review.md b/.workflow/rules/maintainer-review.md index 87b9540..2a6c540 100644 --- a/.workflow/rules/maintainer-review.md +++ b/.workflow/rules/maintainer-review.md @@ -50,6 +50,38 @@ Detail is not the problem and must not be dropped; its position is. Depth that arrives before the question buries it, and the same depth below a stated question supports it. +## Re-establish the subject before asking about it + +A claim is the unit of record. It is not the unit of a question. Recorded +atomically, a claim keeps only what distinguishes it from its neighbors, which +is exactly what a reader who was not there cannot reconstruct: a packet built +straight from one carries a difference with no thing attached to it. "This is +one colour on one side and another on the other side — was that intended?" is a +complete atomic claim and an unanswerable question. + +Before the packet is written, re-establish four things and put them above the +question: + +- **What it is**, named as the product names it, and where a person using or + operating the product meets it. +- **Where it came from** — which sources say this, and when they said it. +- **What turns on it** — the flow it sits in and what changes downstream if the + answer goes either way. +- **What is true right now.** Go back to the pinned source and the current + implementation and check. The candidate was extracted at reading time, before + later findings, and other candidates recorded since may already contradict or + settle it. A packet that repeats a days-old record without re-reading is + asking the maintainer to adjudicate the agent's memory. + +Ask about the subject and list its atomic claims underneath, rather than asking +about each claim. Several claims about one capability are one question; one +claim touching several capabilities is still one question, about the one thing +that has to be decided. + +If the subject cannot be described without file paths or symbol names, that is a +finding to record — the capability has no product-level description yet — and +not a licence to send the identifiers instead. + Translate rather than cite. A term that exists only in the implementation gets replaced by what it does for the product, with the original in parentheses at most once. If a decision genuinely cannot be stated without engineering @@ -75,6 +107,14 @@ and confirming intent that was are different questions, and asking the harder one when the easier one applies is how a body of accepted work becomes an unanswered queue. +Put the framing decision before implementation, not before closure. `wfctl work +issue claim` refuses a delivery issue whose framing is unapproved, which is the +moment to present it: the bundle has been read, nothing is in flight, and the +maintainer's absence blocks nothing yet. Discovering the same gate at completion +parks finished work behind a decision that could have been made on day one. +Approving edits the change record, so refresh its checkpoint and re-read it +before claiming. + For significant work, record framing and completion decisions with `wfctl work approve <id> --stage framing|completion --by human:<maintainer-id>`. That command needs an interactive terminal, or an out-of-band `--token` matching @@ -89,6 +129,15 @@ changes materially. Approving edits the change record, so re-read it, refresh its review receipt, and refresh the checkpoint afterwards. +A checkpoint blocker is the one sentence written for the maintainer rather than +for the next agent, and the only one that reaches them across a session +boundary. That makes a stale blocker worse than a missing one: a missing blocker +makes the next session look, and a stale blocker makes it act — reopening a +decision already made, or recommending an option since proved impossible. When +an answer, a discovery, or a change removes what a record was waiting for, +rewrite its blocker in the same turn. Nothing validates blocker text against the +record it hangs on; only the person changing the record can keep it true. + After every material maintainer turn, update the spec's mutable current state and append the decision/discussion ledger before continuing. Preserve proposed, approved, rejected, deferred, and superseded outcomes without copying the diff --git a/.workflow/rules/project-work-lifecycle.md b/.workflow/rules/project-work-lifecycle.md index 5420db9..fececff 100644 --- a/.workflow/rules/project-work-lifecycle.md +++ b/.workflow/rules/project-work-lifecycle.md @@ -27,8 +27,13 @@ repository owns significant-work bundles and current project knowledge. 10. Preserve implementation in the exact clean Git commit only with normal maintainer authorization. 11. Run `verify-project-work` across the whole bundle and every bound source - revision, promote verified durable truth, obtain completion approval, and - close honestly. + revision, obtain completion approval, then promote verified durable truth + and close honestly. That order is not a preference: a curated page cites the + change as its authority, and that citation resolves only once the change is + receipt-ready, which requires the recorded completion approval. Drafting the + pages earlier is useful and costs nothing; writing them into `knowledge/` + earlier leaves the corpus invalid so that progress can look further along + than it is. On a clean session or resume, run `wfctl work context --stage resume` without an ID. It may auto-select only one bound active record; multiple records require a diff --git a/.workflow/state.json b/.workflow/state.json index 6cd44f3..4f9e1c9 100644 --- a/.workflow/state.json +++ b/.workflow/state.json @@ -10,13 +10,13 @@ "sha256": "2f156cedf6773cbb17357d755d404141936f5acc4fd7a4a33f0fc2a3068b9d8b" }, ".claude/rules/maintainer-review.md": { - "sha256": "5bae3c18f1cec779f1b9e9f8842127a9afe58ac37a47562890be14872f0741ce" + "sha256": "505b43c870ded722211c985164bdaf8e32c7cf6cbd9a3dc146e30cf6caa400d5" }, ".claude/rules/project-work-bundles.md": { "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" }, ".claude/rules/project-work-lifecycle.md": { - "sha256": "ac5212a9f8453175007b5bdb0b587e6aca47648d6273ae2cdb0b014d9365ea14" + "sha256": "dcc9cdf70013226c30420589ad409b3b8f01106c76e78d09187565ab66606dc5" }, ".claude/rules/workflow-routing.md": { "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" @@ -31,13 +31,13 @@ "sha256": "2f156cedf6773cbb17357d755d404141936f5acc4fd7a4a33f0fc2a3068b9d8b" }, ".workflow/rules/maintainer-review.md": { - "sha256": "5bae3c18f1cec779f1b9e9f8842127a9afe58ac37a47562890be14872f0741ce" + "sha256": "505b43c870ded722211c985164bdaf8e32c7cf6cbd9a3dc146e30cf6caa400d5" }, ".workflow/rules/project-work-bundles.md": { "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" }, ".workflow/rules/project-work-lifecycle.md": { - "sha256": "ac5212a9f8453175007b5bdb0b587e6aca47648d6273ae2cdb0b014d9365ea14" + "sha256": "dcc9cdf70013226c30420589ad409b3b8f01106c76e78d09187565ab66606dc5" }, ".workflow/rules/workflow-routing.md": { "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" @@ -46,7 +46,7 @@ "sha256": "0bb95f5bc34cb2ee9c3c453a171cd36f60d8c7d4eeb283ea07e8ad91d5b3ef93" }, ".workflow/runtime/guard-stop.mjs": { - "sha256": "4e3068598e0384e7846727e38b755ab0e3f33418fb775a9bb4d3fb34b8cd0bad" + "sha256": "0cb903b30176d722d0cc2c3cf5f215d989b3d03204d654bdde6111ea283f2e18" }, ".workflow/runtime/idle-guard.sh": { "sha256": "8a132664ff43cc732e4da14b33e7b72f89e5facbb26169b3135eac2c64390da2" diff --git a/AGENTS.md b/AGENTS.md index 9a3614a..ccc1034 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,15 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und a hand-written approval receipt fails verification. - Ask one material question at a time, include a recommendation, and update the durable record before continuing. +- Write every maintainer-facing message in the product's own language, not only + review packets. A blocker, a status line, and a progress report reach the same + reader as a decision packet does. Identifiers the workflow generated — + acceptance criteria, issue and discovery numbers, workstream and packet names, + candidate ids, record slugs — mean nothing outside the records that define + them. Name the thing first and attach the identifier after it, if at all: "the + approval gate the tests cannot open (AC-04)", never "blocked on AC-04". The + same holds for internal vocabulary: say what a term does for the product + before, or instead of, using it. - Preserve uncertainty and report missing evidence instead of guessing. - Execute required `wfctl` commands yourself when tool access permits. Do not delegate routine CLI operation, spec editing, or record maintenance to the @@ -70,6 +79,11 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und without anyone opening it is the same as no queue. A capture only the maintainer can settle is created with `--awaits maintainer` and presented to them as one decision at a time, not listed as a backlog. +- After `wfctl upgrade`, commit the files it names in a commit of their own + before continuing. They are tracked project files, and folding them into the + next unrelated commit hides what the upgrade changed. The new agent block and + rules reach a session only at its start, so say plainly that a restart is + needed rather than acting as if the new instructions are already loaded. - Do not create a competing leaf-local spec or issue tracker. Claim one central frontier issue from the exact bound checkout before implementation. Before completion, account for every bundle file at its current hash; a receipt diff --git a/skills-lock.json b/skills-lock.json index 09c7e20..e1e8f6a 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -4,7 +4,7 @@ "align-project-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "961662c0da125d6948f1e1714d5c14fc2357c482b9c623bb35add49c54d48af7" + "computedHash": "99a3e86766230d7f82c2370d3e31ec2437b68983e1cdd5c84b6e4397634debac" }, "analyze-with-graphify": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", From 3edd3a2947adf21cd6c9bd4da5f9f290f0b6edaf Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 12:40:12 +0300 Subject: [PATCH 071/124] fix(liquid): ask the chain for what it serves, not for what it summarises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The testnet backend this wallet configures is a Waterfalls server, chosen because it answers a whole-wallet scan in one request. It serves the descriptor scan and the fee estimates, and answers 404 to /tx/:txid — which is the only route the covenant read used. So the first covenant a live run touched would have failed with "Could not read transaction …: 404", on the endpoint the wallet ships with, before anything else could be exercised. Every Esplora serves /tx/:txid/raw, Waterfalls included, so the read asks for the transaction's bytes and takes the output out of them. That removes the re-encoder as well: the output handed to the signing module is now the chain's own bytes rather than a second opinion assembled from three fields of a summary, which is the same class of fault as paying a covenant its address instead of its script. The address comparison becomes a script comparison. The script is the locking condition; an address is one rendering of it, and rendering is where a difference can hide. Evidence: the parser reads three real Liquid testnet transactions — coinbase, confidential, and an explicit taproot output — and agrees with what an Esplora server independently reports about every output of each. Both production readers were then driven against the configured testnet endpoint: 377 sats/kvb, all three outputs read, the out-of-range refusal intact. 479 tests, gate green. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../index.test.ts | 21 +- .../__fixtures__/testnet-transactions.json | 64 +++++ packages/tx-manifest/src/bytes.ts | 123 ++++++++++ packages/tx-manifest/src/chainRead.test.ts | 84 ++++--- packages/tx-manifest/src/chainRead.ts | 66 ++---- packages/tx-manifest/src/covenant.test.ts | 21 +- packages/tx-manifest/src/covenant.ts | 20 +- packages/tx-manifest/src/index.ts | 3 +- packages/tx-manifest/src/review.test.ts | 98 +++++--- packages/tx-manifest/src/review.ts | 10 +- packages/tx-manifest/src/spentInputs.ts | 89 +------ packages/tx-manifest/src/txOut.test.ts | 114 +++++++++ packages/tx-manifest/src/txOut.ts | 224 ++++++++++++++++++ 13 files changed, 718 insertions(+), 219 deletions(-) create mode 100644 packages/tx-manifest/src/__fixtures__/testnet-transactions.json create mode 100644 packages/tx-manifest/src/bytes.ts create mode 100644 packages/tx-manifest/src/txOut.test.ts create mode 100644 packages/tx-manifest/src/txOut.ts diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 68297a0..4fc7d81 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { txOutAt } from "@humid/tx-manifest"; import groupedManifest from "@humid/tx-manifest/fixtures/p2pk-grouped.manifest.json"; import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; @@ -161,12 +162,20 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { }, }) as never, readFeeRate: () => async () => 1000, - readTxOut: () => async () => ({ - amountSats: "42000", - rawAssetId: POLICY_ASSET, - scriptPubKeyAddress: DERIVED, - scriptPubKeyHex: "5120aabb", - }), + // Answers with bytes and reads them back through the same parser the real reader uses, + // so this cannot hand over an output the chain could not have produced. + readTxOut: () => async () => { + const asset = `01${(POLICY_ASSET.match(/../g) ?? []).reverse().join("")}`; + const value = `01${(42_000).toString(16).padStart(16, "0")}`; + const script = `${(DERIVED_SCRIPT.length / 2).toString(16).padStart(2, "0")}${DERIVED_SCRIPT}`; + const parsed = txOutAt(`02000000000001${asset}${value}00${script}00000000`, 0); + + if (!parsed.ok) { + throw new Error(parsed.reason); + } + + return parsed.txOut; + }, resolveAccount: async () => ({ accountGroupIndex: 0, chain: {}, rawPolicyAssetId: POLICY_ASSET }) as never, scriptPubKeyHexOf: async () => WALLET_SCRIPT, diff --git a/packages/tx-manifest/src/__fixtures__/testnet-transactions.json b/packages/tx-manifest/src/__fixtures__/testnet-transactions.json new file mode 100644 index 0000000..dd3bb98 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/testnet-transactions.json @@ -0,0 +1,64 @@ +{ + "coinbase": { + "txid": "ff290201c8b88efbd06daf5235f24d562263962983f18f009e79018acbce1ccc", + "raw": "0200000001010000000000000000000000000000000000000000000000000000000000000000ffffffff0603e80d270101ffffffff0201499a818545f6bae39fc03b637f2a4e1e64e590cac1bc3a6f6d71aa4443654c1401000000000000000000016a01499a818545f6bae39fc03b637f2a4e1e64e590cac1bc3a6f6d71aa4443654c1401000000000000000000266a24aa21a9ed94f15ed3a62165e4a0b99699cc28b48e19cb5bc1b1f47155db62d63f1e047d45000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000", + "vout": [ + { + "scriptpubkey": "6a", + "value": 0, + "asset": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49" + }, + { + "scriptpubkey": "6a24aa21a9ed94f15ed3a62165e4a0b99699cc28b48e19cb5bc1b1f47155db62d63f1e047d45", + "value": 0, + "asset": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49" + } + ] + }, + "confidential": { + "txid": "4ab44cb161adc829b9654f0952e4fd96ee563cb6aa9f24367e5519dfd28c33aa", + "raw": "020000000102485303c94de7ff7b1deccc3038923e538807cb7fb75a838f7975e5732d1867170100000000ffffffff485303c94de7ff7b1deccc3038923e538807cb7fb75a838f7975e5732d1867170200000000ffffffff040a4129f8b4e0cfaec8e709877cc12b63bfd60bf3ee61e7a3114344ae43a547476f09a20082f7dd82e07e6e666b8adfdd0b6cc6ee34bd784d3bade22e1143e349e3630317fd433969b38c15a06ca270d27d81f1473bd93b99552548f97fb58357b18e3917a914e24ef199bbbb9b74e6fbc41bf81a4309de490bcf870ac0aa7cad4b84696b300c5131e00a1f6b97e675b9c96f07c552da8495154b327a09dd64ace1bb89cb43e36ace844e35da732d1db7daeb47bdd6620ee5ffcdaef5610264fc9e65d43369600cb657a72d37e4fc892ab89593a7acfe23335460c880dae617a9143d7a9c0808af53d4a381e8a5636953572bcbafbf870a1fc60354bd5f55a9bb16ae90f3d9b072eb42e8d6ae2371be0a11bedccbd7bc7909b684820d82ec73376f57d4a57f1c1e05670780ee9b34c8e665b8bf7001659e4d024e3b6844f9b289363d2cf797e3b350cbc9661b47e93c973445727b86b800fdb216001459e4fd79feb7a8c8ec811daa8c20345e5596ed8a01499a818545f6bae39fc03b637f2a4e1e64e590cac1bc3a6f6d71aa4443654c14010000000000000028000000000000000002483045022100e6c7e633bd21cd399903b1d55d140a7a6c780be73116745e82bd08d2a1f6d732022065cb975d1a5e9b362109fbe2c6e91953956196f8ce784589a62bb1a9e2267aee0121036ea0240be2946875562fa628b15190e6a2d271ee33fff873979f9490e8207cad0000000247304402202ce7a7d558acb0e8b1f6f71bc140a4ecd6bce01756a432184ab68ee146cfa02a02205107106626f9af31da45c7c2a72c5346a8f7d1d47e1862285b5d393b039505d50121031c53a2e6de2997a2066d7961f4423817c1ad9b4ba2325a02f01f9d268ef0797b0063020003809e6fecce8f23b2af691a6422bb0164cede63e26215ad4b4285cc6755b872d56d65f4af7209410b9ace20f742fe7bc85b5b14785853cfbf08880ec14bf74fb35844dd0f400a08f8a66ced1463f528f5ed97132cf0cc9522aef62e497055a304fd4e106033000000000000000103e60b00d0fa97e76f4c67af2b0028b5f463634d17047a7073d31413516984026928afcab2192ec0d7739ced5514751c1fbfbefe506aec24471862da3e9f298cc691475d2761fb0884d85145676260bd85593f086383f05eff43f82f2f5b0eb0486a25544b26b9ff890a35e693ec19b16771219aa5567425f6776de2b99515f10a9d08e03be4e5dadb395d899625a0f839a3c94c174f79616623a8e2c1eeb7802900964a254d9fae4d4d59b7e2fde1b9be694ba1c7edf1ec178c91d65ce470d4c8db2770ecac1ec709aca1a2209ebc8b4604baeb9784cdd518b44cb343a7a3dc3b4222a11fef8de95b5eaa69969bc5971d6d1d2f7a6ea778e472df7cec54007603dcba69bf1f31f4e28325dd97157d81f1f999510b257190d1d5486298bef33c46945bf46579168c7405546e1107d873ffed4301a8fe09567f97f48c5fa9158420f1caac32c747d7516d976013b4e944e341dd19315c61f140b9cd7e314aae984927bce9cab99eb2ee57f85241d985da2d26054a2496b8e2b8ae63ee994fa19d00e0efbb452f4bcc58090f2c4233b30462dad0ab3f1cf171f24623d82a7f772745fb0d8b96fba29b614268c14c301e1bae8fe11cf51758e7c4178f7bd64b66d85f9a70f01187ec2dd147beebd13dcf9087b17c0c901e54889ee3aefd0443ed66f35b4a60ba482f6c6b51926c3a409203b13136b8f6a75b887a15a3ad39f1bd87bc85427b3ddfef7fb4892ffb464905d8d052452802f13bf4aad4f57caf973c62b26cc4ae3ce595a8e511c95ba0baa39f2ae8ac128431818738c7422630729d670a319a14b0cfc0278ef161c6c94f22f340092a1f18cead6df8d22af1728fa4065aa62d0a6bb3c24da376e402b5fbdeb7b6c38316b4717ec3784b4d9e3eb49be0b37eaef0c589f3cc68c6051763476c4b64915b9f546c39234489220ed7ba96644e577507b3a3a22a054961702884080c10b4f4679db8900128b01f8c1c1ebac2d7018660208245c1839a2a5d49ece4bc4bb91751b88d3acb107efc6c499a02bd9e224eea0dc2136b834bca417d4c8326211aa1620e3a5af13b9fae4845958ff0aad4de944de646d859b6990cb5d8aab6a137bc47791c8f0d8c44ca719e6c8b2e5815bc336034c1f824c3e29e4dae7b356387d85873647e399a2e8b1144984a4e8e32abcb1ed533934e6af32e60ad133257f495498df61fe81c3cc69968fe04337ed7fcfaabaab8432fd52cc0e1fa2cd9dad8144b6f26fc33e6e411b7b14a649acce0e103a964ff1e4ce6d857eaba4a34d180f9f8cb30f11e4d7e5eeb0d243bc0abe18a205cb736656e28029b8361bddbc34d13446060e86ee41795ca4da48c862d53cf5b6c792ec44c666ea90fb8be64213b75a398bc6936f186043ba8cefede0d0cf2fd2fc9e97da9a11278d8254eacf76c21ce9b21e51ce212743e53b1eeaa3fab240f2b3728af19b70f3ab7ea667c6a1a1f4945245a4ba1ced4811e972be376ad3cb82760e1dd01779ea72d7403686d3103e266b09179ff68d6bf72b2de1191c8c6b6df8a6c7950dd53ba3c936d4efe0a80d60cdb20fbe244e39165ca16c4ac447f180f5a66b1ca1cfd32b7291a40ac25e2ad937a888c0b29b86c4528486aaf2a672506725d57c6e97baf410f064d7fcae1d9d4ce4d1c1ceae31cbb7e642a50d5d47b626c7b4499eea1c271ee539ca9e6353fd59a2881f9252a216243e5ac3a80ba0d5114722b6f9d2b1e01dd6ea873a2011d9ffa0f3b5ae48836bfb2143977b97b57a806351264fe3883a31f65b30e9f0a3fd4e9e615208cb1efcfb06c9eeef455591eba46ba79d681d4240ca0045175d243b38d4a651836f65acbc49e048eaf71b527f3a9f94fd889673be538a6de8b170da46ac1394e7aa5f31100a1ccd0f31db905a374226ccedaa52418c8f7a61f3ed6b20bff23f36a5be9582d68fb4b7807560064e7f0c7a5577ec9881bde614b2a8baa2da9dece653a9753bc8f3b8bcd97b235d1e3c4fcbb80535c49b2c22ce255d4939b45311e02d08a4a928cdbfdae2315926d3d18b96373ce7352ebe27707a90dc551213cb139a444b43d095d5667b802b0605b2670669411e9762b985df08dbaddca9d0b378b8c3067b906a96bdd2bc14345b27e87828dad45720c4ca7fe508ba5b15ff6aa395af6200dd0c196227cf9ba44741279d5c8d150a6b96c3fec384c1a901b016455ff5e7d742db21935aafd98c26c721836a07d0b60a2426478163fd7c93ae85792e4762889472c21279863485724c10e20d144490751e14a563aad0fc1578ce30167f714835554815479668de2c4b535a6867f65a94db5484354088eddb8f199c9cb340327d99131b987dd6daae79cb63ae36cb79365476a45bd6385afc76d5687baa317450987efb5d82fb462c70308f22a161b74ef2dcbcb3ea83bebad2b4ea25254a36cee082ead07da5aeb12299d5015c3558f7a155e6c1d8a58e4a275fb4b8bfc909914df98ffa286b75c12a97ba22c2b625a96f9da902112d9ebe77dc1f8784270070106bd3e69bf89fc89087669312535f5b9b24451cbd7a6733360ef852ea928a6f41ae9e798a3708d59f3623c148e5dff86351024e9f7db5b1246b9cca309a1671d634e3a688fa09d45e7559143d3362a14334cfc222632923c4311639a6ded074675147cdc719449dfa07cceb79649251560b70ebd34dfd843613542c8e85accd97e839aa5f509d08a593261b7ba54eb7b92dbdb62984cfb669bdfb28d0e64094a5520b5793cea62324d85c05f93a805c98386b440d5db4e921ea41c4670910ff7bf76399164e275ee731a4d2522b3dc5c07f6abfb2894b0321e0cdf44651c238aa5812d80bc4e2ff57b0f31199d40c213249767f50fb9a0617c8b70b720e885b0b8f75915ee2ae8c59aff5832fa1b499fcfd416734cb015bbe752ebf85637ebf8e9f124adb66e71e18a72b4ed07e7b7895264927cf65a6c978ab033e3c23d1c0785cd28691f032cec9e48a52d537e3eac919ab855250303aa1b089d9780bb617c702c5cbd8e0e60f67efe9aa79dd37910940361692b539c7942ed6346d552426748c2c35ecada773ab60dd1553cac243214311f5bb546bc75a51d97414aefc3df5905096110639d90214e0c8dfbe966eba4e67755702f7e04ea4387f47953fb9f2eed37b6c07e99087d72d8a96da0ac3d75d727326e9494d38648007fd6ab7a0035f469bac299565839534a0e6bba7e717ab0760b45a2c3d703a657199a091938cc47fc47f5a619f5955f6d62b09a9ed08edea23c2be5ca603cb43fa1868366cf4d88f12d86b40c24ce68628f06f0718fb40428b091e39341f718a2ed5e41013295811644122c26084f9d1f3c685dd8e43f1e4570d7bbb9bb9512289edeeca5a8faa1da865111e4bbb320e22202144c73c0a634a1b2c94c1e3505cd84e82a330a451a826aa40d5cd73e19fa0d8e73c8cc6dafc9b97d5aba4612d598df43810d71d505561feba7d8ecc6c101afbf3667a74e3a86c3106490e8a0eeefac92efc2dc6d8cbf391ce9c9f93e4c8e0a37dbcdbe913b59f208749edda0b0bbb6a38649c8ab379c8e5938fec08702b75948ea13f3ddcd138ada25b006e7b80aef541d186993cbb3f367a71c6c32a22c437d45f9e8641094862fbe53066ddc23fb9f41aad2d5050462dbf96a5886acbe6702aabfb71d92372c6f81513f3d260b79e2c772677f902e0dcf80621d812aebbf5676b9db9c423b2a21873689fab70264b3b9172b329a5630fe993b693b75f39295822688f1952062f39bc1eaeac967bf7a3cd25a226187866e8deacd2e9e9108a0f8e0817a99fe52416c2af6223eb9e686f5393675f1e219ba999eb092d8deeb5a794b3572755882d49aa0eca6cc6692dac0d891955439b90198973e9bfdf2ab2d721464605d696ff916ed33954fd83545e084f9a356cadfcae6c65ffb78260bca2448d9dfeeb3b1d7901371a15bd85dada1b30a231ea871c579fd306c6bcbf229936deadc179e07086550a977faa29d15af06a9de4ee30d4ccdc33cfae4bb6271d453e2efac590da77802b296937750dde170cc2b4b96559f20c85096df9db26e84fab0e7cc63b2bcabc1c9a2c6bfbeb9a348732aa0620c2a7412c5b982b48992e8f43a459e230cb6817169bdc222f2426ec16d898ddd87d48241d9046d47f42414d7702c643fd5dcee105de973edcebd0f2e0b3059c412f655a761f0daa2bc2b451983df2409c8e3581706b3da1f902a4128da6d1113cc8b3e301491106a4ae39a8ce8c0d75d36bab7f3340930f093b623fd322a4c0d0f1954115e23f6cb3a88555cbf0d10d8e98391574ca057fce5a48a9531c6af830aeb6601d0dc18ebf284f4df4e242605caec2720b624757e70e9a5c9dc54fd4af8972268ecf0b20fdc8d076f8c743d2c6ae3aea6df32c0c1c23791ae58e954ec036f900146f05414efa623099f618f2b5965d7b9d29aee90c781a23f9c861f1b36d8b14366cf921571fddcaea82d7b3d2dc867a5f531c62f1d1b51be0d7a34d05ac5e38b7cb46973396d10db72f9cdda72c4ef43549498ef4edc31095993391a91bb9fdceb2b3f096d7546f712f11fd7641d3f81057d69289d4dd874ccc0f153c1d1d9f7cb6dc2f99007f588be73fd57d4b3e375a3744ab673ca1285dc8f79575617ced32ae1c4d8045959daf8812e59a322bb9ff1effba61a8a436d5e879c549fb0dfa1894baf877a255fe49447795a98f54f533ebccf2ab70c85bb6b5f33ba36796fb4d86ca36a57821cf204f3c543df27de566099f947287f1f63f9f8088dc636cc6ec594515c7c86fc4c3df52efc293d9393db30a59a3ac827dd26210854ac240499fdbf6f8e5f8f83aad6a5ba5215d0b993ee8cc7bed098c2498102835e188a261029afe46455e252545a1272a1a5580da7d53ff5bac42b249cf057456fbf3434ef36e5e087bb8315403d2ee236e3dcaefef09e2b498803ff777cfe3e634363cc93d9c5f1e5b3b5a6496c62c1f9067b63b5a7ad8c8a39d828d76a9036963eaa010a19cc4ac4268b0d3759fefa8e6616a362b32049258d313e326d60047a5c197a93366ce40edaaea7871d90428cf20c2671ed743cfe35d003b0eeb1032eb27204f818d1d5453200504e194f67eff9e44ecfa7030a26020a00c282b2f244dcfd638b9ef01e2b33b47029d0a9b0ae3898deaa0145a8789ead4023817459f6163d0e094682afb4274b06572b01dd4dd8cfa8857e6dc408b5c8df31c5b23fb8fe7940160917d79ff15c4ca504e641e4c641d334ece48c302962831207aca7c48ee753f280f4759cd30e5be212dc45c220fd13739715512473c9c41806d7b77b0bf88499376eee6a429b6a1b6aefe0f38bdaa0306cddaaee1b95255a40c8dfdd1b2d75ecf61a7a95753c30fccbadc8a389bf22bc21e255d1c3d2c5038a4e9bfec05edcfc76e9440027898e6bf1544e29be70d8410421d78200ab5d66f40d084a5143ae68018cadb2a327b421b0a8f6a1a326726a77b10a0921a29aaea2b1d08e359fc9e25a17f38d037cf8fc748f969576906a8fb6a0252e815d1f5ca870ed22ab3ac48160d25d5db7ae2c5cd71df73d728dde053fc6958fa3341be71cf45ae3de9a2ff7fb48a735899b49009c9e6a4115c317c0b36416536e8354734db7b4ec93b3ff6089afe8ce0dd900351212533ebfe38f0c979448a102535cd16889eca7270f27b715b325dc200047fa1ed08eaa3de18ef6a56a467f91a98e225c4c566bc9ade8fa3decb51bc306abcc853a176c1f099971ab1b07c0519773bba0f2d8be426ec7385d4bd8ea4548fde437f503e87ef4f5d578508f952309eeb45caeb24689b989b24ad8e253e7a4bcb99aafe39b367fc4dce3d2a76da92cb550de8f23110481630200038f18dea155533670f53276ee23732af62763bfbb8087860581f8869f8f46493b0ced5bafb92305e0d08fecc73470bbefc14ff0d2e1bdee3b7ab8179d69444e7b6f2a611a106b2d274e7ab9d9d60e361413ab7c272fb94a368e930677213733c7fd4e106033000000000000000122361300e595487bfbb8b5e9e5e117a61b0bbc02104e6655ae2edd1f551d1b8ae7e1ae5d79b5fc770552882a5bdf737208d70f54b5d724946586d3eec88d7106da612e67f647ee62fdeffacda93b119f86605da000a6cca7ca1949e90dda6e83a9d3a0c2c39b8a92d848ccc0a1b0136ec882d7c7ca796ae45af46db59c84416d23921ac048e52fd116250f8edd106e14d1d58f2276f62355574f4a2e3ccc75e67c2dcb6935638acc7cbfb7062eb593c91f4f22289affe4d945cc4917af2304316ab87498968e82dc506eff9670e774f9d3284b9badc50ba5a488fb38672da166249060352be5879d26cb629c15f858cf2ba2e066dbd329a8f03630f55a4be53fdad72d58d55e74857696668c93f03424fd9f054e302aa4ad86401792ac2cba9f0be347732dd4bc7c9b3c810328d4aa9e6179b5febe8007b6543467da80a7526e9c6cf50d2e0efc5f8c5b9d65a1d5861ea4f7f936a62b065a9731b5d5e110fe42abc42a335570e7a5d0574fc5b5749fd5289ea1f2d4e4601597e28a44150c7e9e51983bac0439912584916e035d4ca9cee181944091136be966e87877e2864fe78526a9f669406040040889f482df0adbad58bbc2fa599d5142c7c4fb82a27ba418ccf9253711c7f6905898a23c4789ae8eb3148b54639df889cbaadf4d2a400ef4e7394d0cf3792d662e4f5c2fd9d08331734959829d2e10128e0419e2871b4e7653922de8754a66b6d16659368cfe5f7ce5451f01498710d2681c209a26937518c2008b02f44bd7a7e4d628287188a88b3c2f45d63bb06a461684d6bb9bd4a471dc10bfb2fbb016c0a636fd429048ea9b3a08b76c7c787c9621fe5dc99f66422f3631f9a2f85f8a04b4e888881fa0e8924405d9157ee9678e0c87e83d75b6623e726f1d5791ef3cf42e128828470fe4cceb44008f6b9f2dd51356bbc45eb15370e7eec6002c87a2d6d228dba9e2add8e24aca50b1d9863bcfeabc793b899b09fc4b3991975abc3a1c169bad702dda4e2e02d073d577c3456545f77cb3cb65ac0301a52b3b1e91cf724d5ba66c59b736b40ce57c51dad76f2760b831e1ed1b001c789f2cf5a96e3f18478a7d3a05fe7bf4f06e26e837904ae38b07354876520b4d623df29663734f9cd33ac8465ab9429d1eaa0a2a0fd56b64b66d7127a574b7b1c41bd6a7830c08c852c017370685581f4133fa297126beff97b32ff7ec445851312387ccedd483ec03e95a0ba1233bcd4ba855a4e16ae44e7768fb39a976cf2921fbce1e2e0e916307b993a9c1c9834069bc724ae58b5589c745505905c47129cdea852a7153634be1267e0222117223d3db3429361ebfefddc6443e984f7594e555953b879a1c0469f9121828e7b555a04d3d307f88abee1d7a728986b6f897563a369d720f73ca058ba1e893df2dea097706c15c6f123ca1e420cbc4294a40b7e32391b8bdae74234f8cc40bbeeba9ae01d94ab1be67b257c76a8638d0daead706bc1aa97c9310c2647fa523ca2fddf207a26a3d279be1c2fdc33f92f59756540b6dab2cde35dbd4aff098073c91a3e501a84fd32711bc060030d47d99c17aca04908405e2f3c467c096f7db6714cdb9e094df2a9c1344ba4556582335e517c7da11deb66e203179ad70b6c418270900731b8a683db406d005554a3b23ab5bba141bd2ec1eb7d86f4c8c51f4bce0ba1f726aab322a5c1435e627329e72db8bae685f22a75b788df8c245be5e39e1bb585b8566d104c5e60e91ab2c30973db8ccd04a9a97d4530eeb9055bc1c2fc3424242c648ded7f760f6bab41ee74ba1efcc58c448131bb6cf87af342497e64d5f9a5a8bf396adf1225cb5e1f0e6f3b8970e31c1d25619d693ab0de820577288c3043bf4bd93a5c5be2fb8dd83cc803432ded41b20f4cb122adba71274d121dccd08f1d54ba574131635ef1f44e86a89c3df4f746306a747adc533f3befa0b60176413fc0d1ac1385abdadb2c97a1fd56bfa4438998c81ba24a30be3d0a26f822a7a2f5c9d5c126298e0cb4a04d279ab53bae0fdd6ef35bcb6846210234bfd3b2e6c227234de9660693e2cd12e2506aa10b6f51d98767e04738a949fa706d2b1138ad427fc2fbfcad2ed8c2ff4c419deb960b61aa80881016615e8b08a434d66b6e1df38c11119190f2416415d54679c7f968a6f3f66a4c77496a98d58376185d92425430fa46fc4cb1d0e48ca288a62bcb7ca6cc4168b9a36173ebfc428f4714ad2e07e9ba16cd824b80751d162359b4fa04fc0b4b2ecaa89901142c3a8025f38b8b57dc456cab2f9f2288b61fa3bf3e58a1774bc42136f5c309a9141ce2ef3fdcd0f7ad9e954d73cfe83917106d6dd31e708b594924130274c475637c2c0cd8e060d99e383eebeeadd4330a4978eaf5ad7955553c6526752a427a09a270c62abc17b599f75af205ed7865f471adc0d8b85d45fab960ef71cf796dfbc5e4b3e9cbde524cabd0ff0fe7544efa98826b3407ae9e8526bc54ac865f476385945b4eef2af3cc8b962646809d14cab00f44905e0d82d6db06e45568a724d488c29716db8de127cda68303ccc4114b102392862969807303765c609b7c34cf80f338a6a76cf06b171a542ae7afdca97aebd04b06c0be88591444984a3b18c4d1da2202362641e45f5f445e98b55f4f9b47948e05f830490a48aeb714c1345b6793f5de2b844f4797ecb60d31ab1ce30696e65189d3e7a856e497f440b545ebd414b25b81e31ecd5288a4903c57ca281a1acebe210d71ad2919ff8be447632ae99f5a03995b9cb10e914d372b2aa470ff0c79abc1a134a8860a93777e5fb206019d916429719e677abfed64dcbfe5313d7fa975fee1b21326bf1dc6f489ed7ad03acbf822fb5c9959d012f94e5a9f1c151f3e0493dcae9608e56c516a247fd22bade83d7f0d5bec0638ac5905271fb4b39b4b731e47d43e867a87d089caf3c6ffd26021fc9598de91d904e891a65daf67784ed4cbcf9b29040009f3b054c8cdaa0c9b7668d8da7d20fdee9e5c37623e105df56ae4ee92d01bd8a077bc1b986791015058f0f0be2a16ddac44d049cec81fee32e80a8b15db37207c1fa31d4fd3650b365f43e67baf10de806fe803e5de2c6925429e5c8f268391a59ef51cbf8f8f6293b5c0e3e2cb6ccd4fb966e25be9b9ea98009f1ed1a4b84e01405b7d48aaa2611d2cce1e5d244b41a30164b3d8aec138124c231c49f0ec7e76144c1a0031369cd9111c4a24bc7669266f6065841f429499e34618cb3f5ef7ca4caa7975db06c88f7cc4a2d49e2e847bf2f6e2b490f5992d48bbad6d8fb6ae152dcb20350306650a82d8f7803eed0419c7c64f07acf64d6a920293149b84ff8369106bdda77e812890ee27faa165495d08b87dc1708fb1129f357be5424173dbafac27d7689ee08a3d3557b5cd4f7557237118dad68cca4ef3a05cf0a56682e35686c325b9691c992301c3596fbfc442e5090697ef628c89275b48eb84a90cc53c80914203ca14f28e2ae7841cc8fccad9639303e32c6463e1af81e8d4986f5526600306aadba57cfb082fdfc9004250fed121843b9d22a7ac789a88a50d94caebf2d8dd045c99267520d91dea8f7ba74121e0a488115ce85d99aee17c1e28c4a5cc390cfc20b8b327f9b1647c991b236b55d9a316cdd6183a1955bda8af307cd66230b8e540704b8dcababa858b8ed7f0acb4119d5a51d6d69dc6e8cdd7953c7ea2887595e243e26c2093ec3c839af022f544540f5ef8def254d12655f916e347c0929abf092232ebd760d8c80b7b03f6d5847edaeb0ab7a06051eaee4b3371d59ac77172192eb9950ff64f952a1963788fd38204aa3d583ae597e7006231effac67ea846eadd49ebf0fb056330de88c445440d9aaf244beb2d9f28c32147f74c0b574702c46abeb9d19b5bf8981fbabaf0b1d3f40cbb059710be22a72c19a6521df5328ef611c3437e8cd2d055eb2337a7cb312c80a8259b062af03b78dfc26cd02b3c9a9425b7e269664816a7f33a6f6de22b694ef1fe76a704dd9062f9654e75cb63d9cb0397c1571da4f81cf37a4ba681c3ee773cd54da91150f3a1ca6dbf24712a3ac0460a38ef388fb47f9f5ba1711d7c358163109d0e5373b4ce36bfa3fb09484f74f7af655e279c9b7c7f954fea35eac283f6f7ce77472fd8535ca873dd9c9d76cbd3e66bb7abc4ad3bee81edcc5139d814c8fc25e7ab1b4ac77837b100f9e25d8f51e687f947fe0eb6483d6b0099d5fe09e2d0faa103ab4dbd880b9f611d607396bffdf8595e799ab87d572a4f9bd13a90450fe40db43f67c6443d607777b33b7c7266c096bd6f34aa76b345cedfb4fd2ea03c337004c0e8d9bb5a9f78dc65e2d77e84ca4059d043280b2a09a397d38e97222771b4aa98b54557ad647031ee27fb0b83acb0bec248a5c91df46879cf63e42c95921d75d6dd1051039a96db3199300cd2cf59944197b8ac50a1c25243eaeea2cb20ddbfef2a0505b8e3fc7385c5cecaf2ece07f4ec42a0b7c74cc0382e9bbf14757e87b951d900702b81874788431fd874f7af0181d1e5d45c121394581cbe52f446a7185f7933a5d134d002acfc67c0d3b952207f6f37b8647371b1735611b64da8d576754a13d80a95c8cab3c10e228fd701fed69d4d259aace292d252233d373f9485ef997b9dcd470f652979fbade5ba3311c981e8e0a2360cbddec89446d4e8e181dc7ed500b9cec2a47c24f2eddbe739f342fd69df5c5fe997cd44ff60ce262d0fe8bb75bbef5bc1959fb5285e7f5e7433939cf3f522a29f4683c3b47a79d8bd72b2ccd63252845b19ae2d79e1a2aaed84acd5db20354ca6e86de1970d4766a034e581237c47371dd5b5d63f724d3ac9da58774be289b3c35f3367106512255f5b0c7e355b1e6a7d75a439dbf3cf04860f1bb3ca7e0f8279aa0539c82300cec0d05aa63039d03000bbe4b8722cf4f96097e5883e597b204ba79b50cf0b12b9de66e6a462ebf95c27e6f9db4ab4032e5a3f3b94f3e09d5d4bb55962c5ac18a996acff01f8ec493b3e6279a7a3d6aeaa0c38a31c96d7c48d3ac2eb7719cfbabb1628e3c00f28f688dc0267996fb5e0e028422647136d786fa1ddc90faf0c07c185cfa922ceb62be1ff5e717d7ac9e10087a55f35e6a28b720c4eba1e2ff36bd3f288061c07acd5dacb6bf65535c846b38a3c294e584015f6e3bdb5a4b09fab4838a5fb461db9ab50311e710977c4e931a1f77926f204c2bd757f3c8136d1ba47a45896dc533ffc67edf5e10bf972760e5c78bd5e60c924fc71713181f768f23848ca7e9b958428990e1047221332d676ed8c899cc3c86f74badb5cbef106171937a29af444482b4f322a8b08c22c86c7d291e4d96f3914c425c0868cb657bd3cb9e1716eccc193b288d3603da88209490a0fdab0ddd99857cce8476ff7d6b3867d921d07d014de287c9494157144498ebcdc9f1dd4e025de406734b45435535b220642cbf6f2b6c00946f940c1911fec7bae54612487f777ae068c798f46adf011046dee99eb0310a699b1b9f99c5d609b68ad70d3c23d237f3033a11d768854c883b2fed3afa9178c7ced30095ccb5f69d0fad7440f2672826d70c0e81c1b658e17fa7d1b47a436561f93d4d57ac49fb7daa6f1d7c979968f0f48da133f677365b5a696a2209333050161db2fcf831a1a052e77f7d5c22967dbb6e4071e1a64fd8c109a0da4f49b7cc6ede2780ee8bc8896784f00b0f33f9e2344201ba713a32567b9a3419b1b11208b1290e5b60135efcfc9f3a09a9c354624dbb5fcb09df30797f3c942da3fb75ffe601b3fe83d10a8eccf100ec8afbb870eb53937bb56aa35969a665e1b4b5afcc70622ae4f0ef3642735339a28992b36ef82ae116090c78a571f6302000354c7a7105aca1db2e6731c562de018e3f37af41250a34c1b834b45fd66a75e315c818b774f25339de8ce1724829de6e57fd481c7be1a3421a722e9a42087b9e47dc3c0bea364dc89cc51e207e52fcaff28c153fc709a73069a4cc7cfad3591f2fd4e1060330000000000000001e11d2201bc456acbe354868406a29445c7a039d27716c9c07cb2e3e6ed5ff3b2fd9eb80615c7177e6b10a5566df10f428217903fdc12a82e0d196283df34441aa36c04f17284cc7ed07b916364964edacee1a098ef4af44fdc2ac9f25f960d4ef0a6f4861e283f62d29ce28147c31b4868116f72adab4874a0bfddd4e53c413b34a0be39632d1d86e94a61378297b4090a83a130e687e1bce43abb8d9627469d2c5d344f16e4bb4996ea864770c86a47cd1dc249843927bd02ece26b51a1f84c75b75858cbfaebbf6e5874833ba9f07a6e0d0c849a1fc6678371dc48a1b9d6af17ceff4e3bc24de22b0f2516e4f0c4dc51bbcd627959a8e80bd14316e5d782f05943b554014a1f006ec818ef1e273cbd40c35ef052ddb4961a20a0a3a944184f03daafcd010c04000395acb8c18ad028760809d834c9e11a048eb68430d4cee6a321a9837c25a7421864d8c3565a556136f215afeb517f552ef6d99a885d0013b7bcab06e23b9b9b7683906b5e995498098991a38f98f2d67b6ab727294d5e14b0a9e18f0b022fed0eb88a6bc5c326a8f2c5f0845f5470a237d3f9f4e0efd5ca2183214bb404da1400188700ac7d4668337fffae268df9cba4cbfef71c83e769d824a5407c2a176913bfce028fcde4cee7880b0e80f9988ba90fdb507a4950128b8a88c686a7baedd6d2cb2fb401f4788d2d480d11a993ea5a39cf74fd35bead9c37ec8ebfcc302104170c4527ff1b2b7095544d8f46680a783de473848d543c3723627506cc80683627fc001b1d67af20414475de9a89fffeeafdf9de68e15ec7c19d4550c99ef5a54ddfb7af2b4c585912082517019619da0f435bc7af93186a1c63b2a3ff81da98c1bcd59bada82ef340ff6720c779a45c1ab8349c81fa01075bd7f969405cd3c129ce1e5b588d510725617a7cb9cd28f766d2d418d239244eda08a2f7454ec9e481a17efa85a68514ee589b52c532e1a110f950e7742de9cd732764cff59c4c44f1b24e0a97cf1b7838e819ed6a751bd9186c6b5248da0ad4bf5b99dd9536f2b9e640c2111b85bac8464f589de310bc912e8e37f6eb7ba7362657cce4db93b9bfd229d73911e511601d9c1cbf337a220bdf4ece4aa87912fd94301d9e46f88d3dd9f198383aed03cc6b26dd757fc7ce04863f7447d71d26adac433bbac45e29f43b264aa8203e24a9eb9442478a1d87c3f0e10d0efef53bc969069d83ead83c6619c83cd05b01a5ec062a790f8389f8a04a94c65dd73d885e1dce4b9bb6d0db08af4ff3d71f6f43fa34949035990543cd7ac98246b1c8a176346c1b18ccc2bf52fc5a3cb38e709f5abe3ddac28b8e5c53fc409d9d0070545e66f67cf9fd0eff47c13d5f8cc60fb7129a9f7c1fe3dba2defeaca4b721f30c3ad3d273c763741e6ff23f9484bdde5154d1d1c4b53f79122e589e8a86aa18314cfb1e3500c3426aa02891a95286bb2a1d775a6c9ce0b52d7390da5b2fa4c0f19995518b5eb279ba96ed3d2fe16c89eb1ba990406a5fe74f7ed90b073cd2d39f2260a632b96d559d0698768f73065adceed4c05e8e183e4b2d079ae30437e9691b54a0a8c3e0d71af83f82319face972f1e1cd9cfb0ae40f4bd593b5b67c4d5ec0af927b5a43fc6bb6cab3144e1955e06d84c91b73ab9a1edfa36b57dbf7751a9c7beb0a87797cf5eac929e47570188b0c923805cf09c9a0ab75f680f7d021af83a1f36fbb5a6253f2fa8bbf12cca076daedf95f516bf0cc0d765843aca8d936528bbb1abad288c5ff12a07bad76f406fde4e94a81a16e2bb0e7b3e6a67ab7fa0ae6893919a4637143b90326d01136e4b5a3b8f60f24681953e6a4e49512e8923ef45202cf5bfc245557760966e84de7b4a78811e19c0d88eb06ae7c7a788ec77b10397b10cff5d13ec6e59e80085738b9dedbddcf731b74203675e9602ca2de6e13b72a4f0780e9de482def4f556ef764799aece95115cba749d0afdfc10ef8c3cf94970d017b3904613cff6476a6971bebdf7b1596865af53cfc83cdc638049beaee4147703d0792f7d45683337c45c0d5a3bdbe1c11d7f7321d3225fd00b6d9e5bf4cfdde4590d9779f1cef2a8379c5c690564c83ffb47b4b9f172eea78bc7696f451db035cdc07e9e6f0717481deebd9336cbd1658dd399b9026e6aa1ed28d6aef1de5869e6a9e0a757f943ff04a3d5b07222e8f4bed48fa0cf6af4cd50b6fa554cf3f91dccba991210025505f49f3eba033d06ea58b8b585026e625148f6ba084ddef1b4dee483bb10fd0f5cb1d370c94102bd431dc06b7c08eabc8dd2ee9a9ba57020bcce560044ad2d387b6f12552ff96fbd75cdcd22cc9d71a9c93501f3ddcf90266caa384df5de557368a2f9eed6f2b2c2469afeaf5b8f059dee53b2b2a9261a5f70e88a1b4e788a31a6be1361fa2d140c3ede69ff0709c0ec24348c04d2c23cb4ddba495ab29c349edcb96f2eda48feb6b01f0214cb4c57625fbd756ebc4d270644f73a9a979c0c7f7e8f21adc85d7331b921820df78ec08623f008ec707ba685a84313777c7650993f39dedf97db58cf62dc0dab07040bd297a2b4f5c5b48be44ff461b618029128f52035e9c870becaec47a617a3393c5045b40fa00a6c9f15fe237109d4a712e451273dbea21808a0ce3600be4b172622b392fcfcb7edc11a40535217611cc70792c830612f42dd0009dd3208a871fc3394107dbaaa8ab9d64cfdcc7a9f94d71a81026bf6f81247824437e42ec7804f135d22904e594b3b809c021c67314680c32672c57360e14af392bf2b2aa848191a5f71447a21f1eb9bc3c993688cbd99f9004660128b5d072fc71f1c2f433b400063cf67163e0c736973a0e92337f73a1de9be5b4ae9e7e477ba6c187ca5917b0f58ecdc64bdc085243df32a8f12072bd5e803b012402250da72ce45368a51e622d2785b0d4eae07214abf7bcdcc7e011d2ea13c166c5616752243ac34d7f7b1d10fb84b7aa4c4a15247ec690e87d234c89ffe78c3aefb51f1b7008be8ba8463268499ca048f0b3f71c9a30b8c3c8540438391b139b312a38e08bb76c2cbdedaa5d052e75cdae52b49864468088ba55870d297c7a5ed769b0dd4d16cf96b3a7de6d27ee0e9b0105afd1d04356f3fb7c4fef25b4a9e55e887f17e4df2c8c13ee8be43dec5d57555eff842bf726058d34f79ca46558d9257936080ddf938ff1357d46fb0f3ff62c4ddaad5c887f529ab1b373433aa7099a491f244332adc1860fa4e9f6036be8d4d23f5f87775b6defcf5260844e02a0639ff5f0c165f585d1fd13506fe3172b5a924b052ac458a188525ead750891b12ca0d17e2e324ec6520cb272358cc13f1249295198193739abf03492241dd7a8a881916e4d6195eca31cd365542b00bb63f45707b11345b27c41ad4d8bec78cb606af20cbf66660998db6b33d12d4b6564cb9b8a29c9bd3dda974838e9e9c9fb735ae1296de2935e2472e0323d1cd934e0e131fc87d9960b522e4b8e16697e04d01aeb4ea2728ddc4c4f49f1ed7eb3c33b480d7d0312db358340545094ed0cd1a61e2c3e43ad15b5e0fb80cf43c09d69bca278467a499a57e79881976e36060a08a330a2d0f1a721e945d6fcd3e295ca8c28c79daf466013aaf07f9330038a084749ed9f274a18677f0dd36d9ff41f9f3f4cf16080856dc612ac90b2886ee4a796c16d9ba5c2cf39dedac83820b725c1d87133ae19b8c5acbcc737e87c2d1777dc440d8682b9d2b8dd7414a85a36ec54fe64ea14609e8cc056300b675d251b091c3498f8f0e6781320efbcda0e880f90d873d5fc070b5bd3e10fd0328808091b88e6b1de320af9002fa13951705117b6e522f0dfbafda9d46de5e04ad593c9434e5cffe28d98ab33be2b312b4cd40ba1550dd99e8a2b9ef78522fd7f8c63f3693c9dd30ffcf1a4a9d71173a9105ac42c18f9f9106854b5f193de99e7b015af252f331ea6008042dd48509026b06caa4e98c99f9137012d525c02abdbbcbf8293b5e0a5d3f77021143e78bd4efbd8c2caca98c70452ccc8fa66f6c9f92a5593241b32df1b4ab20eb0f1b89e918ee791d5e7a7d2ec7d70eb50a84aff728e56c78f00ca7853c85c7f5088d201763605db84500e4480c22d5aed55687f6b8af5d9b9baf2a9f8938a4c1a10358d99a331c502e125004a80de9198e57cf22c3bbe0d201bbf8a21beb3a6e014aa16bd561eca2cd7fd86f90fdb368fe18d65ab34ce1f4114de4e72df93f76f9efc4cf8568b1028660db818c66dcd8667187c09075d7398f775bcee7fd2d78eb2d80831b2b551b38d9cc2c33dc992b48cc25fda0c6522c50f839cf9ac349bcd46df2cc8ba4be06930abe8055f677ce7d540a14779a8b4a3032ed4650fa451798f438e651bfc0f75e1e0a192249d66ad1758ce494927531546427ced857e06a4d245935e1f7063859d5fa64a5f933733d02cd68afddaf9ce6c1dc6058a323063c916515c158b91c0be61ce110ab381772821571ae63dd1baadb025e9ada397c60ff38ddc9a3ff11f7184a8694fd9b331c544d2975ad3435a28fd18324c50e242c939fc7e76bce388f5be608e7afe7d79e204888157720254e33956a340bd51305fb905ed0e48e816498455426df2d121dc649d5305bf734711bf15ce24f57c85cd9ea2a0a3bc1f80fe63e8430b29e7926a63b48c89ce5545cae481f489835ff495564f977196961adfab27924975b809b00eeb2b5d7ad751195b1e698bbad8b6013760e90f5ba910952dab65d5797971631db4f3a4d250d60b35f707d2179e70d153855fff3008f722270df5719037232aff08bdb9887b3c564eca2ff5c7057d9a38a3b9d8818a7b729b5c6d1100fe8b3f05130c3af4914007ac8e56cca696ca8b080e832bc89a61b1cdfa363e21d1662e2181933bea7f8d006c9b7d151d854ba0bec059c5ef33792a783444cdb6ab76ffd592d2d52534818d6eba6a3805cd216d652be0951c5819cfc752a8817c0dfc99faa0320a42259aab0f96c7c89ece118833e62826ad67bee251f4e472d7081c65d77e71a8fc3f64cf97aba7d195f9e7ad37fab249a5ae0c30cc16201848246060da3bf8c5e0fe69c93efa1e47aaf21cedcaceed59edf2946d7d62fda639e86b28c13ed5e0f580a3c9fa36fad0df8cae5cdd428931d17d399e908c7e7d89c2afcf8c6a540b7d57f78bd554541525453ddcabc41f2f5c4830c9c7e2780568c31b3568f2bdc17b6ba943f7e6fc2263be40e451aeb76441027bfa957d19488ffd7b27de4b36f1223409aeb6032e7ed79f757372319be6ea27c3848eee197e949608f19c42cea672dc319c3e2a1cea44abc27e2cbc4d04196d76d3a0ba7519229b2a2accf48968602429ef14b6408ad0f4d42cba68afb8df389bf011afd1be144dc4e45e3bfa0be83ea10bf6eccf392c3eea2924ff02592a7ed24a4fb0b8f5f494b525938a12291d05c877064c06dc68cfb89a85a567ec602bbf67b91daa482a96847ffd4579383c2eae22aa5634a13a303e31f331f8e32a1d87f436e0a31b2b6019b05bd01b6bd92b74ee53bdfa45a3bc4bef0725a08da7b8223e15022bc0285ee141f44e1bce8e966fbb073f4d78bdc3ddcbaab700e333397e27ff14334011ef7fea9298658f20f2b5f25af8a2826296a389a6b946dc32e2ce6f9b495dc731ea4c71fa8713e6cffc4fc9763042f45fca11ae769d9b3fb320abb2e788f09f0c673037e36811bc6900887e019513f9563132a14d375f11e20d0b5d8202a474e626e8752b52747165fb60ff2a9935b0ef4b33489f2a509ea21c6966336f44005bb9933202df033a3205df8f639856e1da34f00f7159b590276541009757f43be3eaab57f7776606d58d9aea1933f91959e7b86ea0f1cbac33bc2a051d0000", + "vout": [ + { + "scriptpubkey": "a914e24ef199bbbb9b74e6fbc41bf81a4309de490bcf87", + "scriptpubkey_address": "9144zXSitcVXnbESg8NjWLx7EGdjBXrith" + }, + { + "scriptpubkey": "a9143d7a9c0808af53d4a381e8a5636953572bcbafbf87", + "scriptpubkey_address": "8k2XopgQRbzKuV2W69RTHMMWSRjVzkHC3a" + }, + { + "scriptpubkey": "001459e4fd79feb7a8c8ec811daa8c20345e5596ed8a", + "scriptpubkey_address": "tex1qt8j06707k75v3myprk4gcgp5te2edmv2pmkugd" + }, + { + "scriptpubkey": "", + "value": 40, + "asset": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49" + } + ] + }, + "explicitTaproot": { + "txid": "3fdfabc254967406bb1f7fafd97b157d6a5f361aacd91b6a503ff93d9525273e", + "raw": "020000000102066d1a84f690feb3a64cc1441fe967d0c50b893705ab135488773d17ba4957340000000000000000003096f79680c389fa22679bd0227bb58f3ad149515d3aa6570fb62ecbb389a88d01000000000000000003016d2e8d0b6ed50fed18846b59058e1b849f47d4cf932e6c81abaaa9f2c9d9b3fe0100000000000000010022512013554a1812273adee1a4a94792f3e36d6cdd20407d8563c60455e5a0a18e3ccd01499a818545f6bae39fc03b637f2a4e1e64e590cac1bc3a6f6d71aa4443654c140100000000000b49ed00225120a733a1362f4d951c3361152f22a8bf6fdb19fd8ca069ca1ff3ff967b88593b9001499a818545f6bae39fc03b637f2a4e1e64e590cac1bc3a6f6d71aa4443654c1401000000000000012c0000e40d2700000004d900270dc5c004e1bc817d784010000fa6787926ce43b772476672387bbd28897315ceaf44fe4b3bc56c0992b74cb779d6c903f6de94b1e54fe9093eae52d5e3e9934418ad9557190b3b028c801fea1488280007d510beb7a6247017dfd1a195484894379cc6e8c26243709bf9614e7a28bad0433bee85dc5829b3dbc628a8602286ceb7b1b1ac12ec0354c2eeafe3d899da230b90640003e9b2a2bf1805f765c712122c05fc36279a8f5eebdeed10d4ed033cb11e81d532028c9a513003f95d9a76fc5f90760ab4351fd96bd37e48ce0456a62573fbd747a678fdfa04e937ba0508313f0023b884102667d16969ffb55ab8c81c21f58b2adcb0835a08608af59d042f033764339cd8e6ba33e706809ad3ab2042050831061c2c1aae40840a0da8f98a00cd02837004d8e2028205835580840a0dc25360d408160d540840a0dc447e2c38e38e164d568210283716eac4100c00e00070a038900e2b038d80e3c03900138e8fc6c6e381477110210284fc2020dc81147711821028481c8e40a1362a2400840a0dc9239060a80e4120506e4d1b93271c70b101c9a40a0dca3038d8140da0706038601c42071581c66072d80e5e93f0e0808160cdba0118043a05083102863371bac6022acd40f5aa62fc15442bf36c8c26ea41822039e0fc7acd2043941a41881434349fb1bda2bb07816d94bc46312e83551d9514cb92f78ce40f6d08bdff14e4b20c40a10620506e670c3733c718838e305885a42c37a6c8000000001165fffffffe0c02516040a1369cb58a1a707702cbbe9ba172ab9bc3b83e2d91bc61b39d496b9663c37bb1b0bfa041f841c7e0e70a90618720c150b405b87c4e3902836e38e390283739a61c6e740e30e3f3a871870b10b50dce39c71b69c71c2c46dc71c720c150b40f81c814240e789028373c26e7ace385800e6f5028373d26e7ecc38e162039c640a0dcf91ba010e38e38e166039d140a0dcfe81ce820506e7f8e38e38e38dd00c2841c61c71c71c2d2d8c04240e5f1f8a8e385834e0388200e6a44c4fd0600816d0141c46071401c68071f01c8703918072448141f95e726c800000004102c1a5c41081420c3338128213600008160cdb4100739a7e85330fcc19c71ba160e1569c0b1041f98738e133d381e20816617000b818fcd48201c44071683cc881ccf81cd301cd610284da7ffde6f68b7d5d31f022e607b08c0ac4452fc502dca805dc562bdc502a66cde98107e6f8fcde8a0dcde9871871c6e6fc502d216d01c110284da71c753afe839ee22f062334c89f8a43976aa3704256520c2da2aaeec7435c8984107e067e06283f3a061c61c71c6e74c502d416e01c5081426d2c3fcb7c5b6f8b6c2d1bc7e68779515ebc7d1905cf8c465c077850ce266b5e704083f043f04141f89ce30e38e38dcf38a05a82dc038f10284da6b239d11918eb86d7a1ec3114a2811d1e044f7b30723ce82dd3799ed10a380cdc107e087e08283f141c61c71c71c6e7f0502d616f01c9140a0dd0068141b9f64d90000000182058336e20803a114fc34eb21dcd650006a0a80e6900e84cf00cc6e803374000a0581070285a8051703018838681c42071b81c8c03942072d40e83503a0fc0e84120507e8e938e38e38fd0a628d380620803a1bd3f0a0805e84f1401c640743b0b6609f87030409c509b4b6974685b76a87f68c4235ac82c70dc24fa3ea67c9973640d5d554f964ecd9ff021061891606744339c859e7ff6f8d33f9afa73734e1c908684feedc8c4d0a6112d3bf3613173747b8601801985ac0744601d116838c48b01b8f83b80a064b30000000000000001070a0ab36e40803a268fd0a0074521fa13be1a810848a03c3f870201905a80745481d151f0d00e9179091420c0f1601801a02748e0d920506e37038c4140cc0e1c171f00e8bf03a2f8dc64071683581bc0e2609c5c071681d18c2722cfd24f040b600a2e240188b8d00662e4280d20e4b81c9c09ca40368b9400378b95203808396c074629a2271c304bc326eec87e45512b93b90a5ffb732d723b561984acef6bc34b6ca54300e8cc07a3440e8d307a3600e8da03a37c2e8e301c341d26c00208e8b22444e945530475616e3e6c585c20343296a8edb82a728f3758d8194ea6841be50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac01d589f3370563cff4133cc89e6a88d4ec855828ce1ea8a2b211544edfff3c1970000000141582b57e18c44759c30d4c763b5acec1e6ef74cc7d81e52d6f1a55b6c82625f3156e584b044de168d198094ce0ee59f003a34de259858e7a44dcbd644f35ca0170100000000000000", + "vout": [ + { + "scriptpubkey": "512013554a1812273adee1a4a94792f3e36d6cdd20407d8563c60455e5a0a18e3ccd", + "scriptpubkey_address": "tex1pzd255xqjyuadacdy49re9ulrd4kd6gzq0kzk83sy2hj6pgvw8nxsydweg8", + "value": 1, + "asset": "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d" + }, + { + "scriptpubkey": "5120a733a1362f4d951c3361152f22a8bf6fdb19fd8ca069ca1ff3ff967b88593b90", + "scriptpubkey_address": "tex1p5ue6zd30fk23cvmpz5hj929ldld3nlvv5p5u58lnl7t8hzze8wgqjsnr3k", + "value": 739821, + "asset": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49" + }, + { + "scriptpubkey": "", + "value": 300, + "asset": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49" + } + ] + } +} diff --git a/packages/tx-manifest/src/bytes.ts b/packages/tx-manifest/src/bytes.ts new file mode 100644 index 0000000..78277a9 --- /dev/null +++ b/packages/tx-manifest/src/bytes.ts @@ -0,0 +1,123 @@ +/** + * Reading consensus-encoded bytes. + * + * Shared by everything in this package that reads a transaction rather than asking a module + * what it contains: the guard that checks what a finished transaction spends, and the read + * of what the chain says sits at an outpoint. + */ + +export type Reader = { at: number; bytes: Uint8Array }; + +export function decodeHex(hex: string): Uint8Array | undefined { + const digits = hex.startsWith("0x") ? hex.slice(2) : hex; + + if (digits.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(digits)) { + return undefined; + } + + return Uint8Array.from(digits.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16)); +} + +export function encodeHex(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export function skip(reader: Reader, count: number): boolean { + if (count < 0 || reader.at + count > reader.bytes.length) { + return false; + } + + reader.at += count; + + return true; +} + +/** Takes `length` bytes as hex, leaving the reader after them. */ +export function readHex(reader: Reader, length: number): string | undefined { + if (reader.at + length > reader.bytes.length) { + return undefined; + } + + const slice = reader.bytes.slice(reader.at, reader.at + length); + + reader.at += length; + + return encodeHex(slice); +} + +/** A txid is serialised in reverse of how it is written, which is why it is turned back. */ +export function readReversedHex(reader: Reader, length: number): string | undefined { + if (reader.at + length > reader.bytes.length) { + return undefined; + } + + const slice = reader.bytes.slice(reader.at, reader.at + length); + + reader.at += length; + + return encodeHex(slice.reverse()); +} + +export function readUint32(reader: Reader): number | undefined { + if (reader.at + 4 > reader.bytes.length) { + return undefined; + } + + let value = 0; + + for (let offset = 3; offset >= 0; offset -= 1) { + value = value * 256 + (reader.bytes[reader.at + offset] ?? 0); + } + + reader.at += 4; + + return value; +} + +/** A big-endian unsigned integer, which is how Elements writes an explicit amount. */ +export function readUintBigEndian(reader: Reader, length: number): bigint | undefined { + if (reader.at + length > reader.bytes.length) { + return undefined; + } + + let value = 0n; + + for (let offset = 0; offset < length; offset += 1) { + value = value * 256n + BigInt(reader.bytes[reader.at + offset] ?? 0); + } + + reader.at += length; + + return value; +} + +export function readVarint(reader: Reader): bigint | undefined { + const first = reader.bytes[reader.at]; + + if (first === undefined) { + return undefined; + } + + reader.at += 1; + + const widths: Record<number, number> = { 0xfd: 2, 0xfe: 4, 0xff: 8 }; + const width = widths[first]; + + if (width === undefined) { + return BigInt(first); + } + + if (reader.at + width > reader.bytes.length) { + return undefined; + } + + let value = 0n; + + for (let offset = width - 1; offset >= 0; offset -= 1) { + value = value * 256n + BigInt(reader.bytes[reader.at + offset] ?? 0); + } + + reader.at += width; + + return value; +} diff --git a/packages/tx-manifest/src/chainRead.test.ts b/packages/tx-manifest/src/chainRead.test.ts index 04518d2..79b83a9 100644 --- a/packages/tx-manifest/src/chainRead.test.ts +++ b/packages/tx-manifest/src/chainRead.test.ts @@ -1,11 +1,15 @@ import { describe, expect, test } from "bun:test"; +import transactions from "./__fixtures__/testnet-transactions.json"; import { createEsploraFeeRateReader, createEsploraTxOutReader } from "./chainRead"; -// Response shapes are Esplora's documented ones: /tx/:txid returns a transaction whose -// vout entries carry scriptpubkey and scriptpubkey_address. +// The transaction read asks for /tx/:txid/raw and gets consensus bytes back. The fee read is +// still JSON, which is Esplora's own shape for /fee-estimates. const TXID = "a".repeat(64); +/** A real Liquid testnet transaction whose first output is explicit and taproot-locked. */ +const FIXTURE = transactions.explicitTaproot; + function respondWith(body: unknown, ok = true, status = 200) { const calls: { init?: RequestInit; url: string }[] = []; @@ -13,6 +17,10 @@ function respondWith(body: unknown, ok = true, status = 200) { calls.push({ init, url: String(url) }); return { + arrayBuffer: async () => + typeof body === "string" + ? Uint8Array.from(body.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16)).buffer + : new ArrayBuffer(0), json: async () => body, ok, status, @@ -22,40 +30,47 @@ function respondWith(body: unknown, ok = true, status = 200) { return { calls, fetchImpl }; } -const OUTPUT = { - asset: "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d", - scriptpubkey: "5120aabb", - scriptpubkey_address: "tex1p_covenant", - value: 5000, -}; - describe("createEsploraTxOutReader", () => { - test("reads the requested output", async () => { - const { fetchImpl } = respondWith({ - vout: [{ scriptpubkey: "00", scriptpubkey_address: "other" }, OUTPUT], - }); + test("reads the requested output out of the transaction's bytes", async () => { + const { fetchImpl } = respondWith(FIXTURE.raw); const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + const expected = FIXTURE.vout[0]; - await expect(read({ txid: TXID, vout: 1 })).resolves.toMatchObject({ - amountSats: "5000", - scriptPubKeyAddress: "tex1p_covenant", - scriptPubKeyHex: "5120aabb", + await expect(read({ txid: TXID, vout: 0 })).resolves.toMatchObject({ + amountSats: String(expected.value), + rawAssetId: expected.asset, + scriptPubKeyHex: expected.scriptpubkey, }); }); - test("asks the configured endpoint, trailing slash or not", async () => { - const { calls, fetchImpl } = respondWith({ vout: [OUTPUT] }); + // Every output of the same transaction, so an index is not being ignored. + test("reads each output of a transaction as the chain reports it", async () => { + const { fetchImpl } = respondWith(FIXTURE.raw); + const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + + for (const [index, expected] of FIXTURE.vout.entries()) { + await expect(read({ txid: TXID, vout: index })).resolves.toMatchObject({ + scriptPubKeyHex: expected.scriptpubkey, + }); + } + }); + + // The raw route rather than the summary one: the Waterfalls server this wallet configures + // for Liquid testnet answers 404 to /tx/:txid and serves /tx/:txid/raw, and every Esplora + // serves both. Asking for the summary made the first live run impossible. + test("asks for the transaction's bytes, trailing slash or not", async () => { + const { calls, fetchImpl } = respondWith(FIXTURE.raw); const read = createEsploraTxOutReader({ url: "https://esplora.example/" }, fetchImpl); await read({ txid: TXID, vout: 0 }); - expect(calls[0]?.url).toBe(`https://esplora.example/tx/${TXID}`); + expect(calls[0]?.url).toBe(`https://esplora.example/tx/${TXID}/raw`); }); // A private or authenticated backend is configured once, for lwk; this read must not // need it configured a second time. test("sends the endpoint's configured headers", async () => { - const { calls, fetchImpl } = respondWith({ vout: [OUTPUT] }); + const { calls, fetchImpl } = respondWith(FIXTURE.raw); const read = createEsploraTxOutReader( { headers: [{ name: "authorization", value: "Bearer token" }], @@ -70,7 +85,7 @@ describe("createEsploraTxOutReader", () => { }); test("rejects something that is not a transaction id before asking anyone", async () => { - const { calls, fetchImpl } = respondWith({ vout: [OUTPUT] }); + const { calls, fetchImpl } = respondWith(FIXTURE.raw); const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); await expect(read({ txid: "nope", vout: 0 })).rejects.toThrow(); @@ -78,7 +93,7 @@ describe("createEsploraTxOutReader", () => { }); test("rejects a negative output index before asking anyone", async () => { - const { calls, fetchImpl } = respondWith({ vout: [OUTPUT] }); + const { calls, fetchImpl } = respondWith(FIXTURE.raw); const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); await expect(read({ txid: TXID, vout: -1 })).rejects.toThrow(); @@ -93,21 +108,32 @@ describe("createEsploraTxOutReader", () => { }); test("fails when the transaction has no output at that index", async () => { - const { fetchImpl } = respondWith({ vout: [OUTPUT] }); + const { fetchImpl } = respondWith(FIXTURE.raw); const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); await expect(read({ txid: TXID, vout: 7 })).rejects.toThrow(); }); - // A confidential output has no scriptpubkey_address in some Esplora deployments; - // failing loudly beats returning an object with an empty address that a comparison - // would then match against nothing. - test("fails when the output came back without a scriptPubKey", async () => { - const { fetchImpl } = respondWith({ vout: [{ value: 1 }] }); + // Failing loudly beats returning something shaped like an answer: a caller that got an + // empty script would compare a rebuilt covenant against nothing. + test("fails when what came back is not a transaction", async () => { + const { fetchImpl } = respondWith("0200"); const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); await expect(read({ txid: TXID, vout: 0 })).rejects.toThrow(); }); + + // A confidential output parses; what it cannot do is report an amount or an asset. The + // refusal belongs to the caller that needs one, not here. + test("reads a confidential output without inventing its amount", async () => { + const { fetchImpl } = respondWith(transactions.confidential.raw); + const read = createEsploraTxOutReader({ url: "https://esplora.example" }, fetchImpl); + const result = await read({ txid: TXID, vout: 0 }); + + expect(result.scriptPubKeyHex).toBe(transactions.confidential.vout[0].scriptpubkey); + expect(result.amountSats).toBeUndefined(); + expect(result.rawAssetId).toBeUndefined(); + }); }); describe("createEsploraFeeRateReader", () => { diff --git a/packages/tx-manifest/src/chainRead.ts b/packages/tx-manifest/src/chainRead.ts index b529cab..4d8f26e 100644 --- a/packages/tx-manifest/src/chainRead.ts +++ b/packages/tx-manifest/src/chainRead.ts @@ -10,6 +10,9 @@ * not be. Broadcast stays with lwk. */ +import { encodeHex } from "./bytes"; +import { txOutAt } from "./txOut"; + export type OutPoint = { txid: string; vout: number }; export type TxOutAtOutPoint = { @@ -17,35 +20,12 @@ export type TxOutAtOutPoint = { amountSats?: string; /** Raw asset id, when the output is explicit rather than confidential. */ rawAssetId?: string; - /** The address the output pays to, as the endpoint reports it. */ - scriptPubKeyAddress: string; /** The output's scriptPubKey in hex. */ scriptPubKeyHex: string; + /** The output exactly as the chain holds it, to hand to something that spends it. */ + txOutHex: string; }; -/** - * Re-encodes an explicit output so it can be handed to something that spends it. - * - * Only valid for an explicit output, which for this purpose is not a limitation: a - * covenant output is always unblinded, because Simplicity's introspection jets cannot read - * a confidential commitment. A confidential one returns undefined rather than a plausible - * encoding, since guessing here would produce a transaction that fails far away from the - * cause. - */ -export function encodeExplicitTxOut(txOut: TxOutAtOutPoint): string | undefined { - if (txOut.amountSats === undefined || txOut.rawAssetId === undefined) { - return undefined; - } - - // Elements consensus encoding: explicit asset (0x01 + 32 bytes, reversed), explicit - // value (0x01 + 8 bytes big-endian), null nonce, then the script with its length. - const assetLittleEndian = (txOut.rawAssetId.match(/../g) ?? []).reverse().join(""); - const value = BigInt(txOut.amountSats).toString(16).padStart(16, "0"); - const scriptLength = (txOut.scriptPubKeyHex.length / 2).toString(16).padStart(2, "0"); - - return `01${assetLittleEndian}01${value}00${scriptLength}${txOut.scriptPubKeyHex}`; -} - export type ReadTxOut = (outpoint: OutPoint) => Promise<TxOutAtOutPoint>; export type EsploraEndpoint = { @@ -58,6 +38,13 @@ export type EsploraEndpoint = { * * The endpoint is the one the chain record already configures for lwk, headers included, * so a private or authenticated backend keeps working without being configured twice. + * + * It asks for the transaction's bytes rather than a server's summary of them, for two + * reasons. The summary route is not universal — the Waterfalls server this wallet uses for + * Liquid testnet serves the descriptor scan lwk needs and answers 404 to a plain transaction + * lookup, while every Esplora serves the raw one. And the bytes are what the signing module + * has to be handed, so taking them directly removes a re-encoding that could differ from the + * chain by a byte and fail somewhere far from the cause. */ export function createEsploraTxOutReader( endpoint: EsploraEndpoint, @@ -74,7 +61,7 @@ export function createEsploraTxOutReader( throw new Error(`Not an output index: ${vout}`); } - const response = await fetchImpl(`${base}/tx/${txid}`, { + const response = await fetchImpl(`${base}/tx/${txid}/raw`, { headers: Object.fromEntries((endpoint.headers ?? []).map(({ name, value }) => [name, value])), }); @@ -82,32 +69,13 @@ export function createEsploraTxOutReader( throw new Error(`Could not read transaction ${txid}: ${response.status}`); } - const body: unknown = await response.json(); - const outputs = isRecord(body) && Array.isArray(body.vout) ? body.vout : undefined; - - if (!outputs) { - throw new Error(`Transaction ${txid} came back without outputs.`); - } - - const output = outputs[vout]; - - if (!isRecord(output)) { - throw new Error(`Transaction ${txid} has no output at index ${vout}.`); - } - - const scriptPubKeyHex = output.scriptpubkey; - const scriptPubKeyAddress = output.scriptpubkey_address; + const parsed = txOutAt(encodeHex(new Uint8Array(await response.arrayBuffer())), vout); - if (typeof scriptPubKeyHex !== "string" || typeof scriptPubKeyAddress !== "string") { - throw new Error(`Output ${txid}:${vout} came back without a scriptPubKey.`); + if (!parsed.ok) { + throw new Error(`Reading ${txid}:${vout}: ${parsed.reason}`); } - return { - ...(typeof output.value === "number" ? { amountSats: String(output.value) } : {}), - ...(typeof output.asset === "string" ? { rawAssetId: output.asset } : {}), - scriptPubKeyAddress, - scriptPubKeyHex, - }; + return parsed.txOut; }; } diff --git a/packages/tx-manifest/src/covenant.test.ts b/packages/tx-manifest/src/covenant.test.ts index a184cfe..b117c34 100644 --- a/packages/tx-manifest/src/covenant.test.ts +++ b/packages/tx-manifest/src/covenant.test.ts @@ -114,19 +114,32 @@ describe("covenantMatchesChain", () => { }; test("matches when the rebuilt contract lands where the funds are", () => { - expect(covenantMatchesChain(derivation, "tex1p_derived")).toEqual({ matched: true }); + expect(covenantMatchesChain(derivation, SCRIPT)).toEqual({ matched: true }); }); - test("refuses when it does not, naming both addresses", () => { - const result = covenantMatchesChain(derivation, "tex1p_somewhere_else"); + // Hex case is a spelling of the same bytes, and a comparison that treats it as a + // difference refuses a covenant that is in fact where the funds are. + test("matches whatever case the chain reports the script in", () => { + expect(covenantMatchesChain(derivation, SCRIPT.toUpperCase())).toEqual({ matched: true }); + }); + + test("refuses when it does not, naming what was rebuilt", () => { + const result = covenantMatchesChain(derivation, `5120${"22".repeat(32)}`); expect(result.matched).toBe(false); if (!result.matched) { expect(result.reason).toContain("tex1p_derived"); - expect(result.reason).toContain("tex1p_somewhere_else"); + expect(result.reason).toContain("p2pk_output"); } }); + + // The address is a rendering of the script and the two are not interchangeable. Comparing + // the rendering would pass an address that spells the same script differently and, worse, + // accept a script the wallet never derived because something upstream handed it an address. + test("does not accept the address in place of the script", () => { + expect(covenantMatchesChain(derivation, derivation.address).matched).toBe(false); + }); }); // Extra taproot leaves are part of the covenant address, so they are encoded from the utxo diff --git a/packages/tx-manifest/src/covenant.ts b/packages/tx-manifest/src/covenant.ts index a3a425b..262d7d9 100644 --- a/packages/tx-manifest/src/covenant.ts +++ b/packages/tx-manifest/src/covenant.ts @@ -151,21 +151,26 @@ export async function deriveCovenantAddress( } /** - * Whether a covenant UTXO is what the manifest claims: does the address the wallet - * derived match the one the funds are actually sitting at? + * Whether a covenant UTXO is what the manifest claims: does the script the wallet + * derived match the one the funds are actually locked by? * - * `onChainAddress` must come from the chain, never from the request. Comparing two + * `onChainScriptPubKeyHex` must come from the chain, never from the request. Comparing two * values the same site supplied would pass for any pair it chose to make consistent. - * The state file carries an outpoint and no address precisely because the address has + * The state file carries an outpoint and no script precisely because the script has * to be read rather than told. * + * The comparison is over the script rather than the address it is written as. The script is + * the locking condition itself; an address is one rendering of it, and rendering is where a + * difference can hide — the same script has a different address on a different network, and + * two spellings of one address are not equal as strings. + * * A mismatch is a refusal. There is no shape of this function that returns a warning. */ export function covenantMatchesChain( derivation: CovenantDerivation, - onChainAddress: string, + onChainScriptPubKeyHex: string, ): { matched: true } | { matched: false; reason: string } { - if (derivation.address === onChainAddress) { + if (derivation.scriptPubKeyHex.toLowerCase() === onChainScriptPubKeyHex.toLowerCase()) { return { matched: true }; } @@ -173,7 +178,8 @@ export function covenantMatchesChain( matched: false, reason: `The ${derivation.utxoType} contract rebuilds to ${derivation.address}, ` + - `but the funds are at ${onChainAddress}. This is not the contract the site described.`, + "but the funds are locked by a different contract. " + + "This is not the contract the site described.", }; } diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts index 4dac494..44fbc7f 100644 --- a/packages/tx-manifest/src/index.ts +++ b/packages/tx-manifest/src/index.ts @@ -11,10 +11,11 @@ export { type ReadFeeRate, type ReadTxOut, + type TxOutAtOutPoint, createEsploraFeeRateReader, createEsploraTxOutReader, - encodeExplicitTxOut, } from "./chainRead"; +export { type ParsedTxOut, txOutAt } from "./txOut"; export { type ConfirmationModel, type ShownConfirmation, diff --git a/packages/tx-manifest/src/review.test.ts b/packages/tx-manifest/src/review.test.ts index 6ec22c5..04ce913 100644 --- a/packages/tx-manifest/src/review.test.ts +++ b/packages/tx-manifest/src/review.test.ts @@ -2,8 +2,10 @@ import { describe, expect, test } from "bun:test"; import groupedManifest from "./__fixtures__/p2pk-grouped.manifest.json"; import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import type { TxOutAtOutPoint } from "./chainRead"; import { estimateFeeSats } from "./fee"; import { isRefusal, reviewManifestAction } from "./review"; +import { txOutAt } from "./txOut"; import type { ParsedLiquidProcessCtParams } from "./types"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; @@ -16,6 +18,8 @@ const DERIVED = "tex1p_derived"; // the address is what a person is shown and what an on-chain output is compared against, // the scriptPubKey is what an output pays to, and only one of them is hex. const DERIVED_SCRIPT = "5120" + "11".repeat(32); +/** A script that is not the covenant's, for the cases where the chain must disagree. */ +const UNSPENT_ELSEWHERE = "5120" + "22".repeat(32); const COMPILED = { address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }; function request( @@ -53,14 +57,29 @@ const deps = { walletScriptPubKeyHex: WALLET_SCRIPT, }; const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +/** + * A chain read that answers with bytes rather than with an object shaped like an answer. + * + * It serialises a real one-output transaction and reads it back through the parser the + * production reader uses, so this substitute cannot return anything the real one could not: + * an output it builds wrongly fails here rather than passing through green and failing in a + * browser. Three faults reached a person through substitutes laxer than what they stood for. + */ const readTxOut = - (address: string, amountSats = "42000") => - async () => ({ - amountSats, - rawAssetId: POLICY_ASSET, - scriptPubKeyAddress: address, - scriptPubKeyHex: "5120aabb", - }); + (scriptPubKeyHex: string, amountSats = "42000") => + async (): Promise<TxOutAtOutPoint> => { + const asset = `01${(POLICY_ASSET.match(/../g) ?? []).reverse().join("")}`; + const value = `01${BigInt(amountSats).toString(16).padStart(16, "0")}`; + const script = `${(scriptPubKeyHex.length / 2).toString(16).padStart(2, "0")}${scriptPubKeyHex}`; + const transaction = `02000000000001${asset}${value}00${script}00000000`; + const parsed = txOutAt(transaction, 0); + + if (!parsed.ok) { + throw new Error(`This substitute built an output the parser cannot read: ${parsed.reason}`); + } + + return parsed.txOut; + }; const spendRequest = (state: unknown) => request({ @@ -80,7 +99,7 @@ describe("reviewManifestAction", () => { test("reports the derived address as not yet on chain", async () => { const result = await reviewManifestAction(request(), { ...deps, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); expect(isRefusal(result)).toBe(false); @@ -106,7 +125,7 @@ describe("reviewManifestAction", () => { readTxOut: async () => { asked += 1; - return { scriptPubKeyAddress: "x", scriptPubKeyHex: "00" }; + return { scriptPubKeyHex: "00", txOutHex: "00" }; }, }); @@ -120,7 +139,7 @@ describe("reviewManifestAction", () => { test("passes when the rebuilt contract lands where the funds are", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { ...deps, - readTxOut: readTxOut(DERIVED), + readTxOut: readTxOut(DERIVED_SCRIPT), }); expect(isRefusal(result)).toBe(false); @@ -138,7 +157,7 @@ describe("reviewManifestAction", () => { test("carries the covenant it verified, ready to be spent", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { ...deps, - readTxOut: readTxOut(DERIVED), + readTxOut: readTxOut(DERIVED_SCRIPT), }); expect(isRefusal(result)).toBe(false); @@ -157,10 +176,22 @@ describe("reviewManifestAction", () => { test("refuses a covenant output the chain reports as confidential", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { ...deps, - readTxOut: async () => ({ - scriptPubKeyAddress: DERIVED, - scriptPubKeyHex: "5120aabb", - }), + readTxOut: async () => { + // A real confidential output: the asset and the value are commitments rather + // than numbers, and a nonce is present. Built as bytes and read back, so this + // is what the chain would actually hand over. + const asset = `0a${"33".repeat(32)}`; + const value = `08${"44".repeat(32)}`; + const nonce = `02${"55".repeat(32)}`; + const script = `${(DERIVED_SCRIPT.length / 2).toString(16).padStart(2, "0")}${DERIVED_SCRIPT}`; + const parsed = txOutAt(`02000000000001${asset}${value}${nonce}${script}00000000`, 0); + + if (!parsed.ok) { + throw new Error(parsed.reason); + } + + return parsed.txOut; + }, }); expect(isRefusal(result)).toBe(true); @@ -171,7 +202,7 @@ describe("reviewManifestAction", () => { test("pays out the amount the chain reports, not one the request supplied", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { ...deps, - readTxOut: readTxOut(DERIVED, "77000"), + readTxOut: readTxOut(DERIVED_SCRIPT, "77000"), }); expect(isRefusal(result)).toBe(false); @@ -188,7 +219,7 @@ describe("reviewManifestAction", () => { test("refuses when the funds are somewhere else", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { ...deps, - readTxOut: readTxOut("tex1p_somewhere_else"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); expect(isRefusal(result)).toBe(true); @@ -197,7 +228,7 @@ describe("reviewManifestAction", () => { test("refuses when the state file lists no such covenant", async () => { const result = await reviewManifestAction(spendRequest({ utxos: [] }), { ...deps, - readTxOut: readTxOut(DERIVED), + readTxOut: readTxOut(DERIVED_SCRIPT), }); expect(isRefusal(result)).toBe(true); @@ -217,7 +248,7 @@ describe("reviewManifestAction", () => { test("refuses before reading anything when the state file is absent", async () => { const result = await reviewManifestAction( request({ action: "Receive", params: { pubkey: PUBKEY } }), - { ...deps, readTxOut: readTxOut(DERIVED) }, + { ...deps, readTxOut: readTxOut(DERIVED_SCRIPT) }, ); expect(isRefusal(result)).toBe(true); @@ -227,7 +258,7 @@ describe("reviewManifestAction", () => { test("refuses a request missing a part the action needs, naming it", async () => { const result = await reviewManifestAction(request({ contractSources: {} }), { ...deps, - readTxOut: readTxOut(DERIVED), + readTxOut: readTxOut(DERIVED_SCRIPT), }); expect(isRefusal(result)).toBe(true); @@ -243,7 +274,7 @@ describe("reviewManifestAction", () => { compile: () => { throw new Error("parse error"); }, - readTxOut: readTxOut(DERIVED), + readTxOut: readTxOut(DERIVED_SCRIPT), }); expect(isRefusal(result)).toBe(true); @@ -263,10 +294,13 @@ describe("reviewManifestAction reads through the runtime core", () => { // into the same transaction, so nothing a person is shown depends on which shape the // site chose. test("reviews a grouped manifest into the same result as the flat one", async () => { - const flat = await reviewManifestAction(request(), { ...deps, readTxOut: readTxOut("unused") }); + const flat = await reviewManifestAction(request(), { + ...deps, + readTxOut: readTxOut(UNSPENT_ELSEWHERE), + }); const fromGrouped = await reviewManifestAction(grouped(), { ...deps, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); expect(isRefusal(fromGrouped)).toBe(false); @@ -281,7 +315,7 @@ describe("reviewManifestAction reads through the runtime core", () => { test("reports the legacy spelling the grouped document used", async () => { const result = await reviewManifestAction(grouped(), { ...deps, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); if (!isRefusal(result)) { @@ -298,7 +332,7 @@ describe("reviewManifestAction reads through the runtime core", () => { test("records the constructs it ignored rather than dropping them", async () => { const result = await reviewManifestAction(request(), { ...deps, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); if (!isRefusal(result)) { @@ -311,7 +345,7 @@ describe("reviewManifestAction reads through the runtime core", () => { test("keeps a load-bearing construct out of the ignored list", async () => { const result = await reviewManifestAction(request(), { ...deps, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); if (!isRefusal(result)) { @@ -326,7 +360,7 @@ describe("who decides the fee", () => { const result = await reviewManifestAction(request(), { ...deps, readFeeRate: async () => 1234, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); expect(isRefusal(result)).toBe(false); @@ -342,7 +376,7 @@ describe("who decides the fee", () => { readFeeRate: async () => { throw new Error("no estimate"); }, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); expect(isRefusal(result)).toBe(true); @@ -384,7 +418,7 @@ describe("an amount that depends on the fee", () => { params: { pubkey: PUBKEY }, state: oneCovenantUtxo as Record<string, unknown>, }), - { ...deps, readTxOut: readTxOut(DERIVED, "42000") }, + { ...deps, readTxOut: readTxOut(DERIVED_SCRIPT, "42000") }, ); test("pays out what the covenant holds, less what the wallet worked the fee out to be", async () => { @@ -426,7 +460,7 @@ describe("what the person is shown", () => { const shown = async () => { const result = await reviewManifestAction(request(), { ...deps, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }); if (isRefusal(result)) { @@ -467,7 +501,7 @@ describe("what the person is shown", () => { test("and marks a covenant it did check as checked", async () => { const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { ...deps, - readTxOut: readTxOut(DERIVED), + readTxOut: readTxOut(DERIVED_SCRIPT), }); if (!isRefusal(result)) { @@ -511,7 +545,7 @@ describe("the mode a protocol declares reaches the compiler", () => { return COMPILED; }, - readTxOut: readTxOut("unused"), + readTxOut: readTxOut(UNSPENT_ELSEWHERE), }).then((result) => ({ result, seen })); } diff --git a/packages/tx-manifest/src/review.ts b/packages/tx-manifest/src/review.ts index e4512ef..24f2c12 100644 --- a/packages/tx-manifest/src/review.ts +++ b/packages/tx-manifest/src/review.ts @@ -1,4 +1,4 @@ -import { encodeExplicitTxOut, type ReadFeeRate, type ReadTxOut } from "./chainRead"; +import type { ReadFeeRate, ReadTxOut } from "./chainRead"; import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; import { resolveComputedParams } from "./computed"; import { type ConfirmationModel, confirmationModel } from "./confirmation"; @@ -267,7 +267,7 @@ export async function reviewManifestAction( }; } - const matched = covenantMatchesChain(derived.derivation, onChain.scriptPubKeyAddress); + const matched = covenantMatchesChain(derived.derivation, onChain.scriptPubKeyHex); if (!matched.matched) { return { reason: matched.reason, refused: true }; @@ -277,9 +277,7 @@ export async function reviewManifestAction( inputs[site.id] = { amount_sat: BigInt(onChain.amountSats) }; } - const txOutHex = encodeExplicitTxOut(onChain); - - if (!txOutHex) { + if (onChain.amountSats === undefined || onChain.rawAssetId === undefined) { return { reason: `The ${site.utxoType} at ${outpoint.txid}:${outpoint.vout} is confidential. ` + @@ -288,6 +286,8 @@ export async function reviewManifestAction( }; } + const { txOutHex } = onChain; + covenantInputs.push({ argumentsJson: derived.derivation.argumentsJson, id: site.id, diff --git a/packages/tx-manifest/src/spentInputs.ts b/packages/tx-manifest/src/spentInputs.ts index f598d21..054adc1 100644 --- a/packages/tx-manifest/src/spentInputs.ts +++ b/packages/tx-manifest/src/spentInputs.ts @@ -1,3 +1,5 @@ +import { decodeHex, readReversedHex, readUint32, readVarint, skip } from "./bytes"; + /** One transaction input, as the outpoint it spends. */ export type OutPoint = { txid: string; vout: number }; @@ -16,7 +18,7 @@ export type SpentInputs = { ok: true; spent: OutPoint[] } | { ok: false; reason: * else's business. */ export function spentInputs(transactionHex: string): SpentInputs { - const bytes = decode(transactionHex); + const bytes = decodeHex(transactionHex); if (!bytes) { return { ok: false, reason: "The signed transaction is not hex." }; @@ -57,88 +59,3 @@ export function spentInputs(transactionHex: string): SpentInputs { return { ok: true, spent }; } - -type Reader = { at: number; bytes: Uint8Array }; - -function decode(hex: string): Uint8Array | undefined { - const digits = hex.startsWith("0x") ? hex.slice(2) : hex; - - if (digits.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(digits)) { - return undefined; - } - - return Uint8Array.from(digits.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16)); -} - -function skip(reader: Reader, count: number): boolean { - if (reader.at + count > reader.bytes.length) { - return false; - } - - reader.at += count; - - return true; -} - -/** A txid is serialised in reverse of how it is written, which is why it is turned back. */ -function readReversedHex(reader: Reader, length: number): string | undefined { - if (reader.at + length > reader.bytes.length) { - return undefined; - } - - const slice = reader.bytes.slice(reader.at, reader.at + length); - - reader.at += length; - - return [...slice] - .reverse() - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - -function readUint32(reader: Reader): number | undefined { - if (reader.at + 4 > reader.bytes.length) { - return undefined; - } - - let value = 0; - - for (let offset = 3; offset >= 0; offset -= 1) { - value = value * 256 + (reader.bytes[reader.at + offset] ?? 0); - } - - reader.at += 4; - - return value; -} - -function readVarint(reader: Reader): bigint | undefined { - const first = reader.bytes[reader.at]; - - if (first === undefined) { - return undefined; - } - - reader.at += 1; - - const widths: Record<number, number> = { 0xfd: 2, 0xfe: 4, 0xff: 8 }; - const width = widths[first]; - - if (width === undefined) { - return BigInt(first); - } - - if (reader.at + width > reader.bytes.length) { - return undefined; - } - - let value = 0n; - - for (let offset = width - 1; offset >= 0; offset -= 1) { - value = value * 256n + BigInt(reader.bytes[reader.at + offset] ?? 0); - } - - reader.at += width; - - return value; -} diff --git a/packages/tx-manifest/src/txOut.test.ts b/packages/tx-manifest/src/txOut.test.ts new file mode 100644 index 0000000..44f5841 --- /dev/null +++ b/packages/tx-manifest/src/txOut.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; + +import transactions from "./__fixtures__/testnet-transactions.json"; +import { txOutAt } from "./txOut"; + +/** + * The fixtures are real Liquid testnet transactions, captured with both the bytes and what an + * Esplora server says about them. The server's summary is the independent authority here: the + * parser is right when it agrees with a component that did not come from this repository. + * + * Three shapes, because the field encoding differs and a parser that handles one lands + * mid-output on the others: a coinbase whose outputs are explicit with zero value, a + * transaction whose outputs hide their amounts, and an explicit taproot output — which is the + * shape every covenant output has. + */ +type Fixture = { + raw: string; + txid: string; + vout: { asset?: string; scriptpubkey: string; scriptpubkey_address?: string; value?: number }[]; +}; + +const fixtures: Record<string, Fixture> = transactions; + +describe("reading an output out of a transaction's own bytes", () => { + for (const [name, fixture] of Object.entries(fixtures)) { + test(`${name}: every output agrees with what the chain reports`, () => { + for (const [index, expected] of fixture.vout.entries()) { + const result = txOutAt(fixture.raw, index); + + expect(result.ok, `output ${index} of ${name}: ${!result.ok && result.reason}`).toBe(true); + + if (!result.ok) { + return; + } + + expect(result.txOut.scriptPubKeyHex).toBe(expected.scriptpubkey); + + if (expected.value === undefined) { + // A confidential output commits to its amount and asset, so neither can be read. + expect(result.txOut.amountSats).toBeUndefined(); + expect(result.txOut.rawAssetId).toBeUndefined(); + } else { + expect(result.txOut.amountSats).toBe(String(expected.value)); + expect(result.txOut.rawAssetId).toBe(expected.asset); + } + } + }); + + test(`${name}: the serialised outputs are exactly the transaction's own bytes`, () => { + // Concatenated, every output must reappear in the transaction unchanged and in order. + // This is what the signing module is handed, so a re-encoding that merely looks right + // is the failure to catch. + let searchFrom = 0; + + for (const index of fixture.vout.keys()) { + const result = txOutAt(fixture.raw, index); + + expect(result.ok).toBe(true); + + if (!result.ok) { + return; + } + + const at = fixture.raw.indexOf(result.txOut.txOutHex, searchFrom); + + expect(at, `output ${index} of ${name} is not in the transaction`).toBeGreaterThan(-1); + searchFrom = at + result.txOut.txOutHex.length; + } + }); + } + + test("an output past the end is a refusal, not an empty answer", () => { + const fixture = fixtures.coinbase; + const result = txOutAt(fixture.raw, fixture.vout.length); + + expect(result).toEqual({ + ok: false, + reason: `The transaction has no output at index ${fixture.vout.length}.`, + }); + }); + + test("bytes that are not a transaction refuse rather than parse to something", () => { + expect(txOutAt("not hex", 0)).toEqual({ ok: false, reason: "The transaction is not hex." }); + expect(txOutAt("0200", 0).ok).toBe(false); + }); + + test("a negative or fractional index is refused", () => { + expect(txOutAt(fixtures.coinbase.raw, -1)).toEqual({ + ok: false, + reason: "Not an output index: -1", + }); + expect(txOutAt(fixtures.coinbase.raw, 1.5)).toEqual({ + ok: false, + reason: "Not an output index: 1.5", + }); + }); + + test("a taproot covenant output reads back as its script, its amount and its asset", () => { + // The one shape that matters most: explicit, non-zero, and locked by a taproot script. + const fixture = fixtures.explicitTaproot; + const result = txOutAt(fixture.raw, 0); + + expect(result.ok).toBe(true); + + if (!result.ok) { + return; + } + + expect(result.txOut.scriptPubKeyHex.startsWith("5120")).toBe(true); + expect(result.txOut.amountSats).toBe(String(fixture.vout[0].value)); + expect(result.txOut.rawAssetId).toBe(fixture.vout[0].asset); + expect(result.txOut.txOutHex.endsWith(result.txOut.scriptPubKeyHex)).toBe(true); + }); +}); diff --git a/packages/tx-manifest/src/txOut.ts b/packages/tx-manifest/src/txOut.ts new file mode 100644 index 0000000..96857e4 --- /dev/null +++ b/packages/tx-manifest/src/txOut.ts @@ -0,0 +1,224 @@ +/** + * Reading one output out of a transaction's own bytes. + * + * The wallet needs three things about a covenant output before it will spend it: the script + * that locks it, so the rebuilt contract can be compared against it; its amount and asset, + * for what a person is shown; and the output exactly as it is serialised, to hand to the + * signing module. + * + * All three come from the same bytes rather than from three fields of a summary. That is not + * tidiness: an output re-encoded from a summary is a second opinion about what the chain + * holds, and a covenant that disagrees with the chain by one byte fails somewhere far from + * the cause. + */ + +import { decodeHex, encodeHex, readHex, readUintBigEndian, readVarint, skip } from "./bytes"; +import type { Reader } from "./bytes"; + +export type ParsedTxOut = { + /** Base-unit amount, when the output is explicit rather than confidential. */ + amountSats?: string; + /** Raw asset id as it is written, when the output is explicit rather than confidential. */ + rawAssetId?: string; + /** The script that locks the output, in hex. */ + scriptPubKeyHex: string; + /** The output exactly as it is serialised: asset, value, nonce, then the script. */ + txOutHex: string; +}; + +export type TxOutAt = { ok: true; txOut: ParsedTxOut } | { ok: false; reason: string }; + +/** + * The output at `vout` of a consensus-encoded Elements transaction. + * + * Walks the whole input list to get there, issuance data included — an input that issues an + * asset carries four more fields, and a reader that does not know that lands mid-output and + * reports something well-formed and wrong. + */ +export function txOutAt(transactionHex: string, vout: number): TxOutAt { + const bytes = decodeHex(transactionHex); + + if (!bytes) { + return { ok: false, reason: "The transaction is not hex." }; + } + + if (!Number.isInteger(vout) || vout < 0) { + return { ok: false, reason: `Not an output index: ${vout}` }; + } + + const reader: Reader = { at: 0, bytes }; + + // Version, then the marker Elements uses to say whether witness data follows. + if (!skip(reader, 5)) { + return { ok: false, reason: "The transaction ends before its inputs." }; + } + + const inputCount = readVarint(reader); + + if (inputCount === undefined) { + return { ok: false, reason: "The transaction declares no input count." }; + } + + for (let index = 0n; index < inputCount; index += 1n) { + if (!skipInput(reader)) { + return { ok: false, reason: `The transaction ends inside input ${index}.` }; + } + } + + const outputCount = readVarint(reader); + + if (outputCount === undefined) { + return { ok: false, reason: "The transaction declares no output count." }; + } + + if (BigInt(vout) >= outputCount) { + return { ok: false, reason: `The transaction has no output at index ${vout}.` }; + } + + for (let index = 0; index < vout; index += 1) { + if (!skipOutput(reader)) { + return { ok: false, reason: `The transaction ends inside output ${index}.` }; + } + } + + const start = reader.at; + const asset = readField(reader, 32); + const value = readField(reader, 8); + const nonce = readField(reader, 32); + const scriptLength = readVarint(reader); + + if ( + asset === undefined || + value === undefined || + nonce === undefined || + scriptLength === undefined + ) { + return { ok: false, reason: `The transaction ends inside output ${vout}.` }; + } + + const scriptPubKeyHex = readHex(reader, Number(scriptLength)); + + if (scriptPubKeyHex === undefined) { + return { ok: false, reason: `The transaction ends inside output ${vout}.` }; + } + + return { + ok: true, + txOut: { + // An asset id is written in reverse of how it is displayed, and every consumer here + // wants the displayed form. + ...(asset.explicit ? { rawAssetId: reverseHex(asset.body) } : {}), + ...(value.explicit ? { amountSats: String(bigEndian(value.body)) } : {}), + scriptPubKeyHex, + txOutHex: encodeHex(bytes.slice(start, reader.at)), + }, + }; +} + +type Field = { body: string; explicit: boolean }; + +/** + * One of Elements' three-state fields: absent, explicit, or a commitment that hides the value. + * + * The prefix says which. `0x00` is nothing at all; `0x01` is explicit and carries as many bytes + * as that field's explicit form takes; anything else is a commitment, which is always 32 bytes + * whatever it commits to. + */ +function readField(reader: Reader, explicitLength: number): Field | undefined { + const prefix = reader.bytes[reader.at]; + + if (prefix === undefined) { + return undefined; + } + + reader.at += 1; + + if (prefix === 0x00) { + return { body: "", explicit: false }; + } + + const body = readHex(reader, prefix === 0x01 ? explicitLength : 32); + + return body === undefined ? undefined : { body, explicit: prefix === 0x01 }; +} + +function skipOutput(reader: Reader): boolean { + if ( + readField(reader, 32) === undefined || + readField(reader, 8) === undefined || + readField(reader, 32) === undefined + ) { + return false; + } + + const scriptLength = readVarint(reader); + + return scriptLength !== undefined && skip(reader, Number(scriptLength)); +} + +/** The issuance and peg-in markers Elements keeps in the top two bits of the output index. */ +const ISSUANCE = 0x80_00_00_00; + +/** What a coinbase input writes where an output index would go. */ +const NO_OUTPOINT = 0xff_ff_ff_ff; + +function skipInput(reader: Reader): boolean { + const previousTxid = readHex(reader, 32); + const index = readRawUint32(reader); + const scriptLength = readVarint(reader); + + if (previousTxid === undefined || index === undefined || scriptLength === undefined) { + return false; + } + + // Script, then sequence. + if (!skip(reader, Number(scriptLength) + 4)) { + return false; + } + + // A coinbase spends nothing, and says so by writing an all-zero transaction and an index + // of every bit set — which happens to include the issuance marker. Reading that as an + // issuance walks 64 bytes into the outputs and reports something well-formed and wrong, + // so the null outpoint is checked before the marker means anything. + const spendsNothing = index === NO_OUTPOINT && /^0*$/.test(previousTxid); + + if (spendsNothing || (index & ISSUANCE) === 0) { + return true; + } + + // An issuance carries a blinding nonce, an entropy, the amount issued and the inflation + // keys, in that order. + return ( + skip(reader, 64) && readField(reader, 8) !== undefined && readField(reader, 8) !== undefined + ); +} + +function readRawUint32(reader: Reader): number | undefined { + if (reader.at + 4 > reader.bytes.length) { + return undefined; + } + + let value = 0; + + for (let offset = 3; offset >= 0; offset -= 1) { + value = value * 256 + (reader.bytes[reader.at + offset] ?? 0); + } + + reader.at += 4; + + return value >>> 0; +} + +function reverseHex(hex: string): string { + return (hex.match(/../g) ?? []).reverse().join(""); +} + +function bigEndian(hex: string): bigint { + const bytes = decodeHex(hex); + + if (!bytes) { + return 0n; + } + + return readUintBigEndian({ at: 0, bytes }, bytes.length) ?? 0n; +} From d896c7272210e6c9e9605d494f78b7ef0a18e2bb Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 12:54:44 +0300 Subject: [PATCH 072/124] fix(rpc): let the wallet's own screens say what went wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every failure the popup showed was "[object Object]". The background serialises a thrown error structurally — {message, code, data} — so a dapp can branch on the code rather than parse a sentence, and the popup client put that object through String(). So the one place the message is written for a person is the one place it did not arrive, and has not since the serialisation became structured. Found on the send screen while funding a contract action: a real refusal, with a real explanation, rendered as its own type name. The client now reads the structured shape and keeps the code and data on the error, since a caller here has as much right to branch as a dapp does. Anything neither string nor message-shaped is rendered as JSON rather than as its type name — an unreadable error is worse than an ugly one. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../extension/src/core/extension-rpc/index.ts | 4 +- .../src/core/extension-rpc/toError.test.ts | 50 +++++++++++++++++++ .../src/core/extension-rpc/toError.ts | 37 ++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/core/extension-rpc/toError.test.ts create mode 100644 apps/extension/src/core/extension-rpc/toError.ts diff --git a/apps/extension/src/core/extension-rpc/index.ts b/apps/extension/src/core/extension-rpc/index.ts index 7430ed3..fb3a92f 100644 --- a/apps/extension/src/core/extension-rpc/index.ts +++ b/apps/extension/src/core/extension-rpc/index.ts @@ -3,6 +3,8 @@ import { definePegasusMessageBus } from "@webext-pegasus/transport"; import type { PegasusMsgProtocolMap } from "@/background"; import { MsgProtocolRequestMethods, MsgProtocolResponseMethods } from "@/helpers/background"; +import { toError } from "./toError"; + const REQUEST_TIMEOUT_MS = 60_000; let requestId = 0; @@ -44,7 +46,7 @@ function getMessageBus(): BackgroundMessageBus { clearTimeout(pendingRequest.timeoutId); if (response.error) { - pendingRequest.reject(new Error(String(response.error))); + pendingRequest.reject(toError(response.error)); return; } diff --git a/apps/extension/src/core/extension-rpc/toError.test.ts b/apps/extension/src/core/extension-rpc/toError.test.ts new file mode 100644 index 0000000..f6dfd2a --- /dev/null +++ b/apps/extension/src/core/extension-rpc/toError.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; + +import { toError } from "./toError"; + +// What the background actually sends: a thrown Error is serialised structurally so a dapp can +// branch on the code. Every one of these shapes has to reach a person as words. +describe("toError", () => { + test("a serialised error arrives as its message", () => { + expect(toError({ message: "This account holds 0 of the 1377 needed." }).message).toBe( + "This account holds 0 of the 1377 needed.", + ); + }); + + test("the structured fields survive, so a caller can still branch on them", () => { + const error = toError({ code: -32_602, data: { reason: "invalid" }, message: "Refused." }); + + expect(error.message).toBe("Refused."); + expect(error).toMatchObject({ code: -32_602, data: { reason: "invalid" } }); + }); + + test("a plain string is already the message", () => { + expect(toError("No handler for method: foo").message).toBe("No handler for method: foo"); + }); + + // The failure this replaces: String({message}) is "[object Object]", so the one place the + // message was written for a person is the one place it did not arrive. + test("nothing renders as [object Object]", () => { + for (const raw of [ + { message: "readable" }, + { unexpected: "shape" }, + ["a", "b"], + 42, + null, + undefined, + ]) { + expect(toError(raw).message).not.toContain("[object Object]"); + } + }); + + test("an object with no message is shown as itself rather than as its type", () => { + expect(toError({ unexpected: "shape" }).message).toBe('{"unexpected":"shape"}'); + }); + + test("something that cannot be described still says something", () => { + const circular: Record<string, unknown> = {}; + circular.self = circular; + + expect(toError(circular).message).toBe("The extension failed and did not say why."); + }); +}); diff --git a/apps/extension/src/core/extension-rpc/toError.ts b/apps/extension/src/core/extension-rpc/toError.ts new file mode 100644 index 0000000..993595b --- /dev/null +++ b/apps/extension/src/core/extension-rpc/toError.ts @@ -0,0 +1,37 @@ +/** + * Turns whatever the background sent back into an error a person can read. + * + * The background serialises a thrown error structurally — `{message, code?, data?}` — so a dapp + * can branch on the code instead of parsing a sentence. Passing that object through `String()` + * produced `[object Object]`, which is what every failure in the wallet's own screens said: the + * one place the message was written for a person is the one place it did not arrive. + * + * The structured fields are kept on the error, since a caller that wants to branch has as much + * right to them here as a dapp does. Anything that is neither a string nor message-shaped is + * rendered as JSON rather than as its type name, because an unreadable error is worse than an + * ugly one. + */ +export function toError(raw: unknown): Error { + if (typeof raw === "string") { + return new Error(raw); + } + + if (typeof raw === "object" && raw !== null) { + const structured = raw as { code?: unknown; data?: unknown; message?: unknown }; + + if (typeof structured.message === "string") { + return Object.assign(new Error(structured.message), { + ...(structured.code === undefined ? {} : { code: structured.code }), + ...(structured.data === undefined ? {} : { data: structured.data }), + }); + } + + try { + return new Error(JSON.stringify(raw)); + } catch { + return new Error("The extension failed and did not say why."); + } + } + + return new Error(String(raw)); +} From 520dc8ba45b4f36695b67509a4c8f83470c5bac1 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 12:58:11 +0300 Subject: [PATCH 073/124] fix(rpc): carry the reason underneath, not just the sentence on top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send adapter attaches what actually went wrong as the cause, with a comment saying it does so "so real errors (broadcast, insufficient funds, address) stay diagnosable instead of collapsing into an opaque WALLET_TRANSFER_FAILED". The serialiser then dropped the cause at the message boundary, so the collapse happened anyway — one layer further out and invisible from the code that took care to prevent it. The cause chain now crosses the boundary, bounded because it can be circular, and the message a person sees carries it: "Could not build, sign, and broadcast the Liquid transfer. — caused by: InsufficientFunds…". Only the message reaches a screen, so a cause kept solely as a field would have been preserved and still unread. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../extension-background/transport/index.ts | 22 +------ .../transport/serializeError.test.ts | 66 +++++++++++++++++++ .../transport/serializeError.ts | 29 ++++++++ .../src/core/extension-rpc/toError.test.ts | 39 +++++++++++ .../src/core/extension-rpc/toError.ts | 48 ++++++++++++-- 5 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 apps/extension/src/core/extension-background/transport/serializeError.test.ts create mode 100644 apps/extension/src/core/extension-background/transport/serializeError.ts diff --git a/apps/extension/src/core/extension-background/transport/index.ts b/apps/extension/src/core/extension-background/transport/index.ts index de848a6..d375e02 100644 --- a/apps/extension/src/core/extension-background/transport/index.ts +++ b/apps/extension/src/core/extension-background/transport/index.ts @@ -18,6 +18,8 @@ import { } from "@/helpers/background"; import { sleep } from "@/helpers/promise"; +import { serializeError } from "./serializeError"; + export type PegasusMsgProtocolMap = { [MsgProtocolRequestMethods.Request]: ExtensionMessage; [MsgProtocolResponseMethods.RequestResponse]: ExtensionMessage<unknown>; @@ -180,26 +182,6 @@ export function registerBackgroundRpc( }); } -/** - * Preserve a structured RPC error across the message boundary. A thrown `WalletRpcError` carries a - * numeric `code` and a `data.reason` the dapp branches on (e.g. skip retrying a user rejection); - * collapsing it to `error.message` — as this used to — dropped both, leaving the dapp a bare string - * it could not classify. Kept structural (no wallet-rpc import) so any error with code/data survives. - */ -function serializeError(error: unknown): unknown { - if (error instanceof Error) { - const structured = error as Error & { code?: unknown; data?: unknown }; - - return { - message: error.message, - ...(typeof structured.code === "number" ? { code: structured.code } : {}), - ...(structured.data === undefined ? {} : { data: structured.data }), - }; - } - - return error; -} - function resolveRequestHandler( sender: Endpoint, method: string, diff --git a/apps/extension/src/core/extension-background/transport/serializeError.test.ts b/apps/extension/src/core/extension-background/transport/serializeError.test.ts new file mode 100644 index 0000000..2b92e61 --- /dev/null +++ b/apps/extension/src/core/extension-background/transport/serializeError.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; + +import { serializeError } from "./serializeError"; + +describe("serializeError", () => { + test("an ordinary error keeps its message", () => { + expect(serializeError(new Error("plain"))).toEqual({ message: "plain" }); + }); + + test("the code and data a dapp branches on survive", () => { + const error = Object.assign(new Error("Refused."), { + code: -32_602, + data: { reason: "invalid_manifest_request" }, + }); + + expect(serializeError(error)).toEqual({ + code: -32_602, + data: { reason: "invalid_manifest_request" }, + message: "Refused.", + }); + }); + + // The reason a handler wraps a failure at all: the wrapper is stable and the cause is what + // actually went wrong. Dropping the cause left the wrapper's sentence as the whole story. + test("the cause survives the boundary", () => { + const wrapper = new Error("Could not build, sign, and broadcast the Liquid transfer."); + wrapper.cause = new Error("InsufficientFunds: missing 1200 satoshi"); + + expect(serializeError(wrapper)).toEqual({ + cause: { message: "InsufficientFunds: missing 1200 satoshi" }, + message: "Could not build, sign, and broadcast the Liquid transfer.", + }); + }); + + test("a chain several deep survives in order", () => { + const third = new Error("third"); + const second = new Error("second"); + second.cause = third; + const first = new Error("first"); + first.cause = second; + + expect(serializeError(first)).toEqual({ + cause: { cause: { message: "third" }, message: "second" }, + message: "first", + }); + }); + + // A cause chain can be circular, and this runs inside the message boundary — an unbounded + // walk here is a hung background rather than a bad message. + test("a circular chain terminates", () => { + const first = new Error("first"); + const second = new Error("second"); + first.cause = second; + second.cause = first; + + const serialized = JSON.stringify(serializeError(first)); + + expect(serialized.length).toBeLessThan(500); + expect(serialized).toContain("first"); + }); + + test("something that is not an error is passed through unchanged", () => { + expect(serializeError("just a string")).toBe("just a string"); + expect(serializeError({ shape: "unknown" })).toEqual({ shape: "unknown" }); + }); +}); diff --git a/apps/extension/src/core/extension-background/transport/serializeError.ts b/apps/extension/src/core/extension-background/transport/serializeError.ts new file mode 100644 index 0000000..7d2d2f6 --- /dev/null +++ b/apps/extension/src/core/extension-background/transport/serializeError.ts @@ -0,0 +1,29 @@ +/** + * Preserve a structured RPC error across the message boundary. A thrown `WalletRpcError` carries a + * numeric `code` and a `data.reason` the dapp branches on (e.g. skip retrying a user rejection); + * collapsing it to `error.message` — as this used to — dropped both, leaving the dapp a bare string + * it could not classify. Kept structural (no wallet-rpc import) so any error with code/data survives. + */ +export function serializeError(error: unknown, depth = 0): unknown { + if (error instanceof Error) { + const structured = error as Error & { code?: unknown; data?: unknown }; + + return { + message: error.message, + ...(typeof structured.code === "number" ? { code: structured.code } : {}), + ...(structured.data === undefined ? {} : { data: structured.data }), + // The cause is where the real reason lives. Handlers wrap a failure in a stable + // wallet error and attach what actually went wrong underneath — insufficient funds, a + // rejected broadcast, an address the chain library would not parse. Dropping it here + // left the wrapper's own sentence as the whole story, which is the opaque outcome the + // wrapping was written to avoid. Bounded, because a cause chain can be circular. + ...(structured.cause === undefined || depth >= MAX_CAUSE_DEPTH + ? {} + : { cause: serializeError(structured.cause, depth + 1) }), + }; + } + + return error; +} + +const MAX_CAUSE_DEPTH = 4; diff --git a/apps/extension/src/core/extension-rpc/toError.test.ts b/apps/extension/src/core/extension-rpc/toError.test.ts index f6dfd2a..d998173 100644 --- a/apps/extension/src/core/extension-rpc/toError.test.ts +++ b/apps/extension/src/core/extension-rpc/toError.test.ts @@ -48,3 +48,42 @@ describe("toError", () => { expect(toError(circular).message).toBe("The extension failed and did not say why."); }); }); + +// A handler wraps a failure in a stable wallet error and attaches the real reason underneath. +// Only the message reaches a screen, so the chain has to be in it. +describe("toError and the cause chain", () => { + test("the reason underneath reaches the message", () => { + const error = toError({ + cause: { message: "InsufficientFunds: missing 1200 satoshi" }, + code: -32_002, + message: "Could not build, sign, and broadcast the Liquid transfer.", + }); + + expect(error.message).toBe( + "Could not build, sign, and broadcast the Liquid transfer. — caused by: InsufficientFunds: missing 1200 satoshi", + ); + }); + + test("the cause is kept as an error too, for a caller that wants the parts", () => { + const error = toError({ cause: { message: "underneath" }, message: "wrapper" }); + + expect((error as Error & { cause?: Error }).cause?.message).toBe("underneath"); + }); + + test("a wrapper that only restates its cause does not say it twice", () => { + expect(toError({ cause: { message: "same" }, message: "same" }).message).toBe("same"); + }); + + test("a chain several deep reads in order", () => { + const error = toError({ + cause: { cause: { message: "third" }, message: "second" }, + message: "first", + }); + + expect(error.message).toBe("first — caused by: second — caused by: third"); + }); + + test("no cause reads exactly as it did before", () => { + expect(toError({ message: "alone" }).message).toBe("alone"); + }); +}); diff --git a/apps/extension/src/core/extension-rpc/toError.ts b/apps/extension/src/core/extension-rpc/toError.ts index 993595b..47a0985 100644 --- a/apps/extension/src/core/extension-rpc/toError.ts +++ b/apps/extension/src/core/extension-rpc/toError.ts @@ -1,10 +1,10 @@ /** * Turns whatever the background sent back into an error a person can read. * - * The background serialises a thrown error structurally — `{message, code?, data?}` — so a dapp - * can branch on the code instead of parsing a sentence. Passing that object through `String()` - * produced `[object Object]`, which is what every failure in the wallet's own screens said: the - * one place the message was written for a person is the one place it did not arrive. + * The background serialises a thrown error structurally — `{message, code?, data?, cause?}` — so + * a dapp can branch on the code instead of parsing a sentence. Passing that object through + * `String()` produced `[object Object]`, which is what every failure in the wallet's own screens + * said: the one place the message was written for a person is the one place it did not arrive. * * The structured fields are kept on the error, since a caller that wants to branch has as much * right to them here as a dapp does. Anything that is neither a string nor message-shaped is @@ -17,12 +17,18 @@ export function toError(raw: unknown): Error { } if (typeof raw === "object" && raw !== null) { - const structured = raw as { code?: unknown; data?: unknown; message?: unknown }; + const structured = raw as { + cause?: unknown; + code?: unknown; + data?: unknown; + message?: unknown; + }; if (typeof structured.message === "string") { - return Object.assign(new Error(structured.message), { + return Object.assign(new Error(describe(structured)), { ...(structured.code === undefined ? {} : { code: structured.code }), ...(structured.data === undefined ? {} : { data: structured.data }), + ...(structured.cause === undefined ? {} : { cause: toError(structured.cause) }), }); } @@ -35,3 +41,33 @@ export function toError(raw: unknown): Error { return new Error(String(raw)); } + +/** + * One sentence carrying the whole chain, because only the message reaches a screen. + * + * A handler wraps a failure in a stable wallet error and attaches what actually went wrong + * underneath. The wrapper alone says "could not build, sign and broadcast the transfer", which + * is true and tells a person nothing they can act on; the cause says which of those it was. The + * `cause` field is kept on the error as well, for a caller that wants the parts rather than a + * sentence. + */ +function describe(error: { cause?: unknown; message?: unknown }): string { + const parts: string[] = []; + let current: { cause?: unknown; message?: unknown } | undefined = error; + + while (current && typeof current.message === "string") { + const message = current.message.trim(); + + // A wrapper that merely restates its cause adds nothing but length. + if (message && !parts.includes(message)) { + parts.push(message); + } + + current = + typeof current.cause === "object" && current.cause !== null + ? (current.cause as { cause?: unknown; message?: unknown }) + : undefined; + } + + return parts.join(" — caused by: "); +} From 088f347ca21d0c5cd48923a918c12d983bc3ee1c Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 13:12:22 +0300 Subject: [PATCH 074/124] fix(liquid): let the wallet pay an address that hides nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A contract action can only spend an explicit output, and the wallet could only receive to and pay a confidential address. So nobody could put money where a contract action could reach it — not from a faucet through the wallet, and not from the wallet itself. The contract identity screen exists to show the address to fund, and funding it was impossible. No fork patch needed: the chain library already has an explicit-recipient path and its wasm binding is already exposed. The ordinary path refuses an address with no blinding key, which is what "Address must be confidential" was, so the recipient's own shape picks the path. Draining already took the address as it was. The confidentiality lost is the point of the address, and the review screen has always said so before anyone confirms. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../lwk/wallet/sendTransfer/index.test.ts | 139 ++++++++++++++++++ .../adapters/lwk/wallet/sendTransfer/index.ts | 13 ++ 2 files changed, 152 insertions(+) create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts new file mode 100644 index 0000000..ca45eb0 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, mock, test } from "bun:test"; + +/** + * Which builder call a recipient gets, and nothing else. + * + * The substitutes hold themselves to the chain library's own rule — the ordinary recipient path + * refuses an address with no blinding key, and the explicit path refuses one that has it — so a + * branch chosen wrongly here fails the way it would fail in a browser rather than passing green. + */ +type Recorded = { calls: string[] }; + +const recorded: Recorded = { calls: [] }; + +function makeBuilder() { + const builder = { + addExplicitRecipient(address: { isBlinded: () => boolean }, satoshi: bigint) { + if (address.isBlinded()) { + throw new Error("Address must be explicit"); + } + + recorded.calls.push(`explicit:${satoshi}`); + + return builder; + }, + addLbtcRecipient(address: { isBlinded: () => boolean }, satoshi: bigint) { + if (!address.isBlinded()) { + throw new Error("Address must be confidential"); + } + + recorded.calls.push(`lbtc:${satoshi}`); + + return builder; + }, + addRecipient(address: { isBlinded: () => boolean }, satoshi: bigint) { + if (!address.isBlinded()) { + throw new Error("Address must be confidential"); + } + + recorded.calls.push(`asset:${satoshi}`); + + return builder; + }, + drainLbtcTo() { + recorded.calls.push("drain"); + + return builder; + }, + drainLbtcWallet() { + return builder; + }, + finish() { + return { toString: () => "pset" }; + }, + }; + + return builder; +} + +const POLICY = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d"; +let blinded = true; + +mock.module("../../loadLwkWasm", () => ({ + loadLwkWasm: async () => ({ + Address: class { + isBlinded() { + return blinded; + } + isMainnet() { + return false; + } + toString() { + return blinded ? "tlq1_confidential" : "tex1_explicit"; + } + }, + AssetId: { fromString: (id: string) => ({ id }) }, + TxBuilder: class { + constructor() { + return makeBuilder() as never; + } + }, + }), +})); + +mock.module("../../sync-worker/createSyncWorkerClient", () => ({ + getSyncWorkerClient: () => ({ broadcast: async () => ({ txid: "sent" }) }), +})); + +const { sendTransfer } = await import("./index"); + +const account = { + accountIdentifier: "acct", + chain: {}, + chainId: "bip122:liquid-testnet", + implementation: { + network: {}, + signer: { sign: (pset: unknown) => pset }, + wollet: { finalize: (pset: unknown) => pset }, + }, + policyAssetId: `bip122:liquid-testnet/asset:${POLICY}`, + rawPolicyAssetId: POLICY, +} as never; + +async function send(overrides: Record<string, unknown> = {}) { + recorded.calls = []; + + return sendTransfer( + account, + { amount: "5000", recipientAddress: "irrelevant", ...overrides } as never, + POLICY, + ); +} + +describe("which builder call a recipient gets", () => { + test("a confidential recipient takes the ordinary L-BTC path", async () => { + blinded = true; + + await expect(send()).resolves.toEqual({ txid: "sent" }); + expect(recorded.calls).toEqual(["lbtc:5000"]); + }); + + // Without this the wallet cannot pay an explicit output at all, and a contract action can + // only spend an explicit one — so nobody could fund one, including from their own wallet. + test("an unconfidential recipient takes the explicit path", async () => { + blinded = false; + + await expect(send()).resolves.toEqual({ txid: "sent" }); + expect(recorded.calls).toEqual(["explicit:5000"]); + }); + + test("draining takes the address as it is, either way", async () => { + blinded = false; + await send({ sendAll: true }); + expect(recorded.calls).toEqual(["drain"]); + + blinded = true; + await send({ sendAll: true }); + expect(recorded.calls).toEqual(["drain"]); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.ts index 2590b7f..8c44b38 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.ts @@ -87,7 +87,20 @@ export async function sendTransfer( // Native "Max": drain every L-BTC input to the recipient, ignoring `amount`. LWK selects all // inputs and subtracts the fee, so the broadcast pays whatever the fee is off the freshly // re-synced UTXO set — no dependence on the amount estimated earlier (no feeRate() = default). + // The drain path takes the address as it is, so an unconfidential one produces an explicit + // output without needing the branch below. builder = builder.drainLbtcWallet().drainLbtcTo(recipientAddress); + } else if (!recipientAddress.isBlinded()) { + // An unconfidential recipient needs the explicit path: the ordinary one refuses an address + // with no blinding key outright ("Address must be confidential"). Without this the wallet + // cannot pay an explicit output at all — which means it cannot fund a contract action, since + // a covenant can only spend an explicit one. The confidentiality that is lost is the point + // of the address, and the review screen says so before anyone confirms. + builder = builder.addExplicitRecipient( + recipientAddress, + amount, + lwk.AssetId.fromString(rawAssetId), + ); } else if (rawAssetId === account.rawPolicyAssetId) { builder = builder.addLbtcRecipient(recipientAddress, amount); } else { From 6a28dedb089cbdcbaf056a0f70ae20b6eda7abcd Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 13:35:12 +0300 Subject: [PATCH 075/124] fix(liquid): let the wallet see its own money that hides nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wollet::utxos walks the unspent cache and then skips every entry whose amount is explicit, so an unblinded output at one of the wallet's own scripts is never listed — the library says as much elsewhere, that such outputs "are considered external". The output is in the cache; only the listing drops it. That is the money, and the only money, a contract action can spend: the signing module gets an outpoint and its bytes, and unblinding needs secrets it is never given. So the wallet could be funded and still report nothing available, which is what happened an hour after it learned to do the funding. The list is built from the wallet's own transactions — each reports which outputs are its own and which inputs spent its own outputs, so unspent is the difference. No network call, no second source, and nothing counted as the wallet's that its own scan did not already claim. Kept separate from getUtxos rather than folded in. That one answers the dapp-facing getUTXOs and the portfolio snapshot, which describe the wallet as the chain library reports it; widening them would change an existing contract to fix a different problem. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../adapters/lwk/createLwkWalletBackend.ts | 3 +- .../adapters/lwk/wallet/getUTXOs/index.ts | 32 ++++ .../wallet/readExplicitWalletUtxos.test.ts | 151 ++++++++++++++++++ .../lwk/wallet/readExplicitWalletUtxos.ts | 98 ++++++++++++ .../backends/LiquidWalletBackend.ts | 8 + .../index.test.ts | 17 +- .../processConfidentialTransaction/index.ts | 9 +- 7 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts index 9c8900d..4a5c2c9 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts @@ -2,7 +2,7 @@ import type { LiquidWalletBackend } from "../../application/backends/LiquidWalle import { getWalletActivityForAsset } from "./wallet/getActivity"; import { getWalletBalanceForAsset } from "./wallet/getBalance"; import { getWalletReceiveAddress } from "./wallet/getReceiveAddress"; -import { getWalletUtxosForAsset } from "./wallet/getUTXOs"; +import { getExplicitWalletUtxosForAsset, getWalletUtxosForAsset } from "./wallet/getUTXOs"; import { getWalletDescriptorEntries } from "./wallet/getWalletDescriptor"; import { createLwkLiquidAccount } from "./wallet/resolveAccount"; import { estimateMaxSend, inspectTransfer, sendTransfer } from "./wallet/sendTransfer"; @@ -17,6 +17,7 @@ export function createLwkWalletBackend(): LiquidWalletBackend { getBalance: getWalletBalanceForAsset, getReceiveAddress: getWalletReceiveAddress, getDescriptorEntries: getWalletDescriptorEntries, + getExplicitUtxos: getExplicitWalletUtxosForAsset, getUtxos: getWalletUtxosForAsset, inspectMessageSigning, inspectTransfer, diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getUTXOs/index.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getUTXOs/index.ts index 85625ef..5a058a5 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getUTXOs/index.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getUTXOs/index.ts @@ -8,6 +8,7 @@ import { mapLiquidUtxosForAsset } from "../../../../application/backends/mapLiqu import type { LiquidUTXO } from "../../../../domain/LiquidRpc"; import { toLiquidAssetId } from "../../../../domain/validation"; import { getLwkImplementation } from "../getLwkImplementation"; +import { readExplicitWalletUtxos } from "../readExplicitWalletUtxos"; import { readWalletUtxos } from "../readWalletUtxos"; export function getWalletUtxosForAsset( @@ -36,3 +37,34 @@ export function getWalletUtxosForAsset( ); } } + +/** + * The wallet's unspent outputs that hide nothing, for the one path that can only spend those. + * + * Deliberately not folded into `getWalletUtxosForAsset`. That one answers the dapp-facing + * `getUTXOs` and the portfolio snapshot, and both describe the wallet as the chain library + * reports it; widening them would change an existing contract to fix a different problem. + */ +export function getExplicitWalletUtxosForAsset( + account: LiquidWalletAccount, + rawAssetId: string, +): LiquidUTXO[] { + const implementation = getLwkImplementation(account); + + try { + return mapLiquidUtxosForAsset(readExplicitWalletUtxos(implementation.wollet), { + assetId: toLiquidAssetId(account.chainId, rawAssetId), + rawAssetId, + }); + } catch (error) { + if (error instanceof WalletRpcResourceUnavailableError) { + throw error; + } + + throw new WalletRpcResourceUnavailableError( + "Could not read the wallet's explicit Liquid UTXOs.", + undefined, + WALLET_RPC_ERROR_REASONS.WALLET_UTXO_READ_FAILED, + ); + } +} diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts new file mode 100644 index 0000000..5731ad4 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; + +import { readExplicitWalletUtxos } from "./readExplicitWalletUtxos"; + +/** + * A wallet built out of the shapes the chain library returns: each transaction reports which of + * its outputs belong to the wallet and which of its inputs spent wallet outputs, and the raw + * output says whether the amount is hidden. + */ +type OutputSpec = { + amount: string; + blinded: boolean; + vout: number; + height?: number; +}; + +function walletTx( + txid: string, + outputs: OutputSpec[], + spends: { txid: string; vout: number }[] = [], +) { + const owned = (spec: OutputSpec) => ({ + address: () => ({ toString: () => `address:${txid}:${spec.vout}` }), + height: () => spec.height, + outpoint: () => ({ txid: () => ({ toString: () => txid }), vout: () => spec.vout }), + scriptPubkey: () => ({ toString: () => `script:${spec.vout}` }), + unblinded: () => ({ + asset: () => ({ toString: () => "asset" }), + value: () => ({ toString: () => spec.amount }), + }), + }); + + return { + inputs: () => + spends.map((spend) => ({ + get: () => ({ + outpoint: () => ({ + txid: () => ({ toString: () => spend.txid }), + vout: () => spend.vout, + }), + }), + })), + outputs: () => outputs.map((spec) => ({ get: () => owned(spec) })), + tx: () => ({ + outputs: outputs.map((spec) => ({ + isPartiallyBlinded: () => spec.blinded, + toString: () => `txout:${txid}:${spec.vout}`, + })), + }), + txid: () => ({ toString: () => txid }), + }; +} + +const wollet = (txs: unknown[]) => ({ transactions: () => txs }) as never; + +const A = "aa".repeat(32); +const B = "bb".repeat(32); + +describe("the wallet's own outputs that hide nothing", () => { + test("an unspent explicit output is reported", () => { + const utxos = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: false, height: 12, vout: 0 }])]), + ); + + expect(utxos).toHaveLength(1); + expect(utxos[0]).toMatchObject({ + amountSats: "30000", + confidential: false, + spendable: true, + txid: A, + txOut: `txout:${A}:0`, + vout: 0, + }); + }); + + // The ordinary read already reports these, and a wallet that counted them twice would + // believe it has more money than it does. + test("a blinded output is left to the ordinary read", () => { + const utxos = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: true, height: 12, vout: 0 }])]), + ); + + expect(utxos).toEqual([]); + }); + + test("an explicit output a later transaction spent is gone", () => { + const utxos = readExplicitWalletUtxos( + wollet([ + walletTx(A, [{ amount: "30000", blinded: false, height: 12, vout: 0 }]), + walletTx( + B, + [{ amount: "20000", blinded: false, height: 13, vout: 0 }], + [{ txid: A, vout: 0 }], + ), + ]), + ); + + expect(utxos.map((utxo) => utxo.txid)).toEqual([B]); + }); + + // The spending transaction can be read before the one it spends from, and a reader that + // decided as it went would report an output it had already been told was gone. + test("order does not decide it", () => { + const utxos = readExplicitWalletUtxos( + wollet([ + walletTx( + B, + [{ amount: "20000", blinded: false, height: 13, vout: 0 }], + [{ txid: A, vout: 0 }], + ), + walletTx(A, [{ amount: "30000", blinded: false, height: 12, vout: 0 }]), + ]), + ); + + expect(utxos.map((utxo) => utxo.txid)).toEqual([B]); + }); + + test("an output still in the mempool is reported, and not as spendable", () => { + const utxos = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: false, vout: 0 }])]), + ); + + expect(utxos[0]).toMatchObject({ spendable: false }); + }); + + test("only the wallet's own outputs, never a counterparty's", () => { + const tx = walletTx(A, [{ amount: "30000", blinded: false, height: 1, vout: 0 }]); + const withStranger = { + ...tx, + outputs: () => [...tx.outputs(), { get: () => undefined }], + tx: () => ({ + outputs: [ + ...tx.tx().outputs, + { isPartiallyBlinded: () => false, toString: () => "somebody-else" }, + ], + }), + }; + + const utxos = readExplicitWalletUtxos(wollet([withStranger])); + + expect(utxos).toHaveLength(1); + expect(utxos[0]?.txOut).toBe(`txout:${A}:0`); + }); + + test("an input the wallet did not own does not remove anything", () => { + const tx = walletTx(A, [{ amount: "30000", blinded: false, height: 1, vout: 0 }]); + const withForeignInput = { ...tx, inputs: () => [{ get: () => undefined }] }; + + expect(readExplicitWalletUtxos(wollet([withForeignInput]))).toHaveLength(1); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts new file mode 100644 index 0000000..f6a24e5 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts @@ -0,0 +1,98 @@ +import { + WALLET_RPC_ERROR_REASONS, + WalletRpcResourceUnavailableError, +} from "@/core/wallet-rpc/errors"; + +import type { LiquidUtxoSnapshot } from "../../../application/backends/LiquidWalletBackend"; +import type { LwkWasmModule } from "../loadLwkWasm"; + +type LwkWollet = InstanceType<LwkWasmModule["Wollet"]>; + +/** + * The wallet's own unspent outputs that hide nothing. + * + * `Wollet::utxos` cannot answer this. It walks the unspent cache and then skips every entry + * whose amount is explicit, so an unblinded output at one of the wallet's own scripts is never + * listed — the library states the same rule in its own words, that "unblinded UTXOs with the + * same scriptpubkeys as the wallet, are considered external". The output is in the cache; only + * the listing drops it. + * + * That matters because a contract action can spend nothing else. Unblinding an output needs the + * secrets that go with it, and the signing module is handed an outpoint and its bytes and + * nothing more — so the money a person can put behind a contract is exactly the money that is + * already in the open. Without this the wallet cannot see what it sent itself. + * + * Built from the wallet's own transactions rather than from a second source: each one reports + * which of its outputs belong to the wallet and which of its inputs spent wallet outputs, so + * what is unspent is the difference. No network call, and nothing is treated as the wallet's + * that the wallet's own scan did not already claim. + */ +export function readExplicitWalletUtxos(wollet: LwkWollet): LiquidUtxoSnapshot[] { + const spent = new Set<string>(); + const candidates = new Map<string, LiquidUtxoSnapshot>(); + + for (const walletTx of wollet.transactions()) { + for (const input of walletTx.inputs()) { + const previous = input.get(); + + if (!previous) { + continue; + } + + const outpoint = previous.outpoint(); + + spent.add(outpointKey(outpoint.txid().toString(), outpoint.vout())); + } + + const txid = walletTx.txid().toString(); + const rawOutputs = walletTx.tx().outputs; + + for (const output of walletTx.outputs()) { + const owned = output.get(); + + if (!owned) { + continue; + } + + const outpoint = owned.outpoint(); + const vout = outpoint.vout(); + const rawTxOut = rawOutputs[vout]; + + if (!rawTxOut) { + throw new WalletRpcResourceUnavailableError( + "Could not locate the raw output for a wallet transaction output.", + { txid, vout }, + WALLET_RPC_ERROR_REASONS.WALLET_UTXO_READ_FAILED, + ); + } + + // The only ones this reader is for. A blinded output is already reported by the + // ordinary read, and reporting it twice would have the wallet count it twice. + if (rawTxOut.isPartiallyBlinded()) { + continue; + } + + const unblinded = owned.unblinded(); + + candidates.set(outpointKey(txid, vout), { + address: owned.address().toString(), + amountSats: unblinded.value().toString(), + confidential: false, + rawAssetId: unblinded.asset().toString(), + scriptPubKey: owned.scriptPubkey().toString(), + // The same conservative reading the ordinary read takes: confirmed is spendable, + // still in the mempool is not. + spendable: owned.height() !== undefined, + txid, + txOut: rawTxOut.toString(), + vout, + } satisfies LiquidUtxoSnapshot); + } + } + + return [...candidates].filter(([key]) => !spent.has(key)).map(([, utxo]) => utxo); +} + +function outpointKey(txid: string, vout: number): string { + return `${txid}:${vout}`; +} diff --git a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts index d5c1477..11f1bd9 100644 --- a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts +++ b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts @@ -135,6 +135,14 @@ export type LiquidWalletBackend = { params: LiquidGetWalletDescriptorParams, ) => Promise<LiquidWalletDescriptorEntry[]>; getUtxos: (account: LiquidWalletAccount, rawAssetId: string) => LiquidUTXO[]; + /** + * The wallet's unspent outputs that hide nothing. + * + * Separate from `getUtxos` because the chain library does not report these as the + * wallet's at all, and because only one path can use them: a contract action cannot + * spend an output whose amount is hidden. + */ + getExplicitUtxos: (account: LiquidWalletAccount, rawAssetId: string) => LiquidUTXO[]; inspectTransfer: ( account: LiquidWalletAccount, params: LiquidSendTransferParams, diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 4fc7d81..c14046d 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -81,15 +81,30 @@ function context(): LiquidProcessCtContext { keyManagerState: {}, walletBackend: { getReceiveAddress: () => ({ address: WALLET_ADDRESS, index: 0 }), - getUtxos: () => [ + // The two lists the method reads, kept honest about which is which: a contract action + // can only spend an explicit output, so the funding one lives in the explicit list and + // the confidential one is there to be held back. A method that stopped asking for the + // explicit list would fail here for want of money rather than pass quietly. + getExplicitUtxos: () => [ { amount: "1000000", + confidential: false, spendable: true, txid: FUNDING_TXID, txOut: "00", vout: 0, }, ], + getUtxos: () => [ + { + amount: "9000000", + confidential: true, + spendable: true, + txid: `${"cc".repeat(32)}`, + txOut: "00", + vout: 0, + }, + ], syncAccount: async () => undefined, }, } as unknown as LiquidProcessCtContext; diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index deda904..a3066b7 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -281,7 +281,14 @@ export const createProcessLiquidConfidentialTransaction = ( policyAsset: account.rawPolicyAssetId, scriptPubKeyOf: ({ argumentsJson, source }) => new smplx.Contract(source, argumentsJson).scriptPubKeyHex(network), - fundingUtxos: context.walletBackend.getUtxos(account, account.rawPolicyAssetId), + // Both lists, because only one of them can pay for this and the other one is why a + // person is short. Selection spends the explicit ones and reports the hidden ones as + // held back, which is the difference between "you do not have enough" and "you have + // enough and it is in the wrong shape". + fundingUtxos: [ + ...context.walletBackend.getExplicitUtxos(account, account.rawPolicyAssetId), + ...context.walletBackend.getUtxos(account, account.rawPolicyAssetId), + ], network, accountLabel: `${account.chain?.id ?? context.chain.id} account ${account.accountGroupIndex}`, readFeeRate: dependencies.readFeeRate(context.chain), From ad36f30f1c0a93202ae7d37016229b69ddbcb32a Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 5 Aug 2026 13:38:01 +0300 Subject: [PATCH 076/124] fix(liquid): offer only the explicit outputs this path can actually sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signing module is handed an outpoint and its bytes and no derivation path, so it signs every wallet input with one key: the account's first external address. An explicit output anywhere else in the range is real money the wallet owns and cannot spend here. Offering it to coin selection would buy a failure inside the signing module — after the person approved — in place of a shortfall stated plainly beforehand. Today only one such output exists and it happens to sit at the right index, so this costs nothing now and is the difference between a refusal and a crash the first time it does not. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ --- .../wallet/readExplicitWalletUtxos.test.ts | 22 ++++++++++++++++++ .../lwk/wallet/readExplicitWalletUtxos.ts | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts index 5731ad4..eaf7ad9 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts @@ -11,7 +11,9 @@ type OutputSpec = { amount: string; blinded: boolean; vout: number; + chain?: number; height?: number; + index?: number; }; function walletTx( @@ -21,7 +23,9 @@ function walletTx( ) { const owned = (spec: OutputSpec) => ({ address: () => ({ toString: () => `address:${txid}:${spec.vout}` }), + extInt: () => spec.chain ?? 0, height: () => spec.height, + wildcardIndex: () => spec.index ?? 0, outpoint: () => ({ txid: () => ({ toString: () => txid }), vout: () => spec.vout }), scriptPubkey: () => ({ toString: () => `script:${spec.vout}` }), unblinded: () => ({ @@ -142,6 +146,24 @@ describe("the wallet's own outputs that hide nothing", () => { expect(utxos[0]?.txOut).toBe(`txout:${A}:0`); }); + // The contract path signs every wallet input with one key, the account's first external + // address. An explicit output anywhere else in the range is money the wallet owns and + // cannot spend here, and offering it would buy a failure at signing — after the person + // approved — instead of a shortfall said plainly beforehand. + test("an explicit output the contract path cannot sign is not offered", () => { + const elsewhere = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: false, height: 1, index: 4, vout: 0 }])]), + ); + + expect(elsewhere).toEqual([]); + + const change = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: false, chain: 1, height: 1, vout: 0 }])]), + ); + + expect(change).toEqual([]); + }); + test("an input the wallet did not own does not remove anything", () => { const tx = walletTx(A, [{ amount: "30000", blinded: false, height: 1, vout: 0 }]); const withForeignInput = { ...tx, inputs: () => [{ get: () => undefined }] }; diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts index f6a24e5..99dbcda 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts @@ -8,6 +8,19 @@ import type { LwkWasmModule } from "../loadLwkWasm"; type LwkWollet = InstanceType<LwkWasmModule["Wollet"]>; +/** `Chain::External` — the side of the descriptor addresses are handed out from. */ +const CHAIN_EXTERNAL = 0; + +/** + * The one index the contract path can sign. + * + * The signing module derives a single key at the account's first external address and signs + * every wallet input with it. Until it takes a derivation path per input, that address is the + * whole of what a contract action can be funded from — the limitation the contract identity + * screen exists to make visible rather than to hide. + */ +const SIGNING_INDEX = 0; + /** * The wallet's own unspent outputs that hide nothing. * @@ -72,6 +85,16 @@ export function readExplicitWalletUtxos(wollet: LwkWollet): LiquidUtxoSnapshot[] continue; } + // And only the ones the contract path can actually sign. That path signs every wallet + // input with one key, the account's first external one, because the signing module is + // given an outpoint and its bytes and no derivation path. An explicit output anywhere + // else in the range is real money the wallet owns and cannot spend here, and offering + // it to coin selection would buy a failure at signing — after the person approved — + // in place of a shortfall said plainly beforehand. + if (owned.extInt() !== CHAIN_EXTERNAL || owned.wildcardIndex() !== SIGNING_INDEX) { + continue; + } + const unblinded = owned.unblinded(); candidates.set(outpointKey(txid, vout), { From 5a96c8dd884bab72a6077ad9e4987d4333872c47 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 11:57:30 +0300 Subject: [PATCH 077/124] chore(workflow): apply wfctl upgrade Installed workflow assets refreshed by `wfctl upgrade`: agent rules on review and continuity, the stop guard runtime, the AGENTS.md managed block, the project-work skill set, and the recorded asset hashes. --- .agents/skills/manage-project-work/SKILL.md | 6 +- .../manage-project-work/assets/work-spec.md | 2 + .../skills/shape-project-direction/SKILL.md | 14 ++- .../skills/specify-project-change/SKILL.md | 36 +++++++- .agents/skills/verify-project-work/SKILL.md | 12 ++- .claude/rules/execution-continuity.md | 8 ++ .claude/rules/maintainer-review.md | 87 ++++++++++++++++++- .claude/skills/manage-project-work/SKILL.md | 6 +- .../manage-project-work/assets/work-spec.md | 2 + .../skills/shape-project-direction/SKILL.md | 14 ++- .../skills/specify-project-change/SKILL.md | 36 +++++++- .claude/skills/verify-project-work/SKILL.md | 12 ++- .workflow/rules/execution-continuity.md | 8 ++ .workflow/rules/maintainer-review.md | 87 ++++++++++++++++++- .workflow/runtime/guard-stop.mjs | 20 +++++ .workflow/state.json | 10 +-- AGENTS.md | 37 ++++++-- skills-lock.json | 8 +- 18 files changed, 359 insertions(+), 46 deletions(-) diff --git a/.agents/skills/manage-project-work/SKILL.md b/.agents/skills/manage-project-work/SKILL.md index 962a7cb..9508866 100644 --- a/.agents/skills/manage-project-work/SKILL.md +++ b/.agents/skills/manage-project-work/SKILL.md @@ -133,8 +133,10 @@ readable for compatibility. Framing and completion decisions are recorded with `wfctl work approve <id> --stage framing|completion --by human:<maintainer-id>`, never by editing -`maintainer_review` directly. The command requires an interactive terminal or -an out-of-band `--token`; a hand-written receipt fails the completion gate. +`maintainer_review` directly. Record what they answered in the session with +`--attested "<their words>" --session "<where>"`; a typed confirmation and an +out-of-band `--token` remain for a maintainer who wants a receipt you could not +have written. A hand-written receipt fails the completion gate. `changes/active/<id>/` is already the knowledge-side living record. There is no final dump into `raw/`. Completed closure moves the entire bundle intact to diff --git a/.agents/skills/manage-project-work/assets/work-spec.md b/.agents/skills/manage-project-work/assets/work-spec.md index 7cb17e3..5e36895 100644 --- a/.agents/skills/manage-project-work/assets/work-spec.md +++ b/.agents/skills/manage-project-work/assets/work-spec.md @@ -27,6 +27,8 @@ direction: knowledge_alignment: reviewed: [] conflicts: [] + # Set covered: false with a basis when no curated concept covers this work. + # An empty knowledge base is a supported state; an invented concept path is not. graph_evidence: queries: [] knowledge_promotion: diff --git a/.agents/skills/shape-project-direction/SKILL.md b/.agents/skills/shape-project-direction/SKILL.md index 318f24e..0280caa 100644 --- a/.agents/skills/shape-project-direction/SKILL.md +++ b/.agents/skills/shape-project-direction/SKILL.md @@ -19,12 +19,20 @@ charting or resuming. evidence leaves; unknown future implementation repositories are not guessed. 2. Run `wfctl work context <id> --stage wayfind` and `wfctl work status <id>`. Do not edit product source during this phase. -3. Name the destination first: what a finished map will make specifiable, for +3. Run `wfctl work repositories <id>` and read what every bound repository + declares about itself — the instructions its maintainer wrote in its own + agent file, and the skills installed only there. A map charted from the + centre is charted without them otherwise, and they are exactly the rules + that decide whether a route is possible in that repository. Invoke + `align-project-knowledge` in the same pass: fog is only fog until you check + whether the project already answered it. `wfctl work map finish` refuses + until every bound repository is accounted for. +4. Name the destination first: what a finished map will make specifiable, for whom, and the boundary it must not cross. -4. Explore breadth-first. Put a precise answerable question into a Wayfinder +5. Explore breadth-first. Put a precise answerable question into a Wayfinder issue. Put only still-unphraseable in-scope uncertainty into `map.md` fog. Put ruled-out work under out of scope. -5. Create currently visible blockers before their dependants with `wfctl work +6. Create currently visible blockers before their dependants with `wfctl work issue create --phase wayfinding --type research|prototype|grilling|task`. Use `wfctl work issue block|unblock` when later evidence changes an edge. Stop charting; do not resolve a normal issue diff --git a/.agents/skills/specify-project-change/SKILL.md b/.agents/skills/specify-project-change/SKILL.md index bee7312..2f250c6 100644 --- a/.agents/skills/specify-project-change/SKILL.md +++ b/.agents/skills/specify-project-change/SKILL.md @@ -33,6 +33,33 @@ the contract, invoke `analyze-with-graphify` in every relevant bound root, then open the actual source locations. Invoke `align-project-knowledge` before settling product or architecture meaning. +Work spanning more than one repository is shaped here, at the centre, because +only the centre sees them all at once. What the centre does not see is what each +repository declares about itself: the instructions its maintainer wrote in its +own agent file, and the skills installed only there. Those are specific and +binding — one repository opens with a plan file to read first, another calls its +navigation rule BINDING — and a session that never entered the checkout has no +way to know they exist. + +```sh +wfctl work repositories <id> +``` + +This prints them without leaving the centre. Read every one, then account for +each bound repository before asking for approval: + +```sh +wfctl work repositories <id> --read <repository> --note "<what its rules require of this work>" +wfctl work repositories <id> --untouched <repository> --reason "<why this work does not reach it>" +``` + +The hash of its instructions and the list of its own skills are taken from the +checkout, not from you, so the receipt binds to what was there; if the +repository changes its rules afterwards the receipt is reported stale rather +than quietly wrong. Framing approval and `wfctl work map finish` both refuse +until every bound repository is one or the other. Saying nothing is not a third +option, and neither is a note that only says the file was opened. + Update `change.md` with: - the problem and intended observable outcome; @@ -55,7 +82,8 @@ binds the current `change.md` rather than an earlier draft. ## Review and continue -Present a compact framing packet: outcome, scope, exclusions, decisions, +Present a compact framing packet — maintainer-facing, so the reader test in +`maintainer-review` governs every sentence: outcome, scope, exclusions, decisions, acceptance IDs, test seams, risks, and unresolved work. Record only explicit maintainer approval, and record it through the approval command rather than by editing the receipt: @@ -66,8 +94,10 @@ wfctl work approve <id> --stage framing \ --note "<what was approved>" ``` -The command requires an interactive terminal, or `--token` matching -`WFCTL_APPROVAL_TOKEN` in unattended use. A hand-written +Render the framing with `wfctl work ask <id>` and record their reply with +`--attested "<their words>" --session "<where>"`. A typed confirmation and a +`--token` matching `WFCTL_APPROVAL_TOKEN` remain available and are theirs to ask +for; do not send them to a second terminal by default. A hand-written `maintainer_review.framing` receipt fails verification. Approving rewrites `change.md`, so re-read it, refresh its review receipt, and refresh the checkpoint afterwards. diff --git a/.agents/skills/verify-project-work/SKILL.md b/.agents/skills/verify-project-work/SKILL.md index cb47054..9d1191e 100644 --- a/.agents/skills/verify-project-work/SKILL.md +++ b/.agents/skills/verify-project-work/SKILL.md @@ -66,7 +66,10 @@ from real work is better than none, and the cost of the shortcut is only hidden when nobody writes it down. Present acceptance results, engineering findings, checks, deviations, risks, -and knowledge delta as one completion review packet. Record the maintainer's +and knowledge delta as one completion review packet. It is maintainer-facing: +the reader test in `maintainer-review` applies, so acceptance is named by what +was asked for rather than by criterion id, and a deviation by what changed for +the product rather than by which file moved. Record the maintainer's explicit decision through the approval command; never write the receipt by hand: @@ -76,9 +79,10 @@ wfctl work approve <id> --stage completion \ --note "<what the maintainer accepted>" ``` -It requires an interactive terminal, or `--token` matching -`WFCTL_APPROVAL_TOKEN`. `wfctl work verify` rejects a receipt with no matching -approval record. Finish all semantic edits to `change.md`, then refresh its +Record what they answered with `--attested "<their words>" --session +"<where>"`; a typed confirmation and a `--token` matching `WFCTL_APPROVAL_TOKEN` +remain for a stronger record. `wfctl work verify` rejects a receipt with no +matching approval record. Finish all semantic edits to `change.md`, then refresh its checkpoint in review stage **before** recording the final hash receipt: ```sh diff --git a/.claude/rules/execution-continuity.md b/.claude/rules/execution-continuity.md index b758a25..202da1e 100644 --- a/.claude/rules/execution-continuity.md +++ b/.claude/rules/execution-continuity.md @@ -19,6 +19,14 @@ to ask; it is evidence the search was not done. acceptance criteria and proven by its terminal status. A finished plan item, a written summary, a passing subtask, and a refreshed checkpoint are progress. +**Check that a stop would lose nothing, rather than believing it.** `wfctl +resumable` answers that from the repository: a checkpoint describing a record +that has since changed, an open record that never had one, and work on disk no +checkpoint describes and no commit preserves are three different losses, and the +last is the one a basis digest structurally cannot see. A non-zero exit is not a +finding to report onward — refresh the checkpoint or commit, then end. The +maintainer asking you to wrap up is the failure this replaces. + **End a turn only when you are waiting on the maintainer.** Ending one hands control to them, so the test is what you are waiting for and not what you wrote. Announcing a next action and stopping is the visible half of this; the diff --git a/.claude/rules/maintainer-review.md b/.claude/rules/maintainer-review.md index 2a6c540..3f3edd3 100644 --- a/.claude/rules/maintainer-review.md +++ b/.claude/rules/maintainer-review.md @@ -19,6 +19,16 @@ Do not request review for freezing a clean Git raw scope, file accounting, QMD refresh, index or log maintenance, or source-backed drafts that keep their trust state honest. Raw intake itself never counts as evidence. +Reading is never a decision. Accounting for what each bound repository declares +about itself, running Graphify in every bound root, checking curated knowledge +before settling meaning — a gate refusing until those are done is telling the +agent to go and do them, not handing the agent a question. Nothing about them +reaches the maintainer: not as a request, not as a status line, and not as an +apology for the delay. The one thing that does reach them is a product +consequence found while reading — a rule in one repository that makes the +proposed route impossible there is a decision, and it is put to them as what the +product must do instead, never as the file it was found in. + Present a compact review packet containing the exact decision, evidence, conflicts, recommendation, and requested response. Record approve, correct, or defer. Ask one focused question at a time. Silence is not approval. @@ -88,6 +98,50 @@ most once. If a decision genuinely cannot be stated without engineering vocabulary, that is a finding rather than a licence: the capability has no product-level description yet, and recording that gap is part of the answer. +## The line, and how to tell which side you are on + +Everything this workflow produces is written for one of two readers, and the two +are not styles of the same text. They are different texts. + +| | Written for the record | Written for the maintainer | +| --- | --- | --- | +| Reader | The next agent, the compiler, an auditor | A person deciding about their product | +| Addresses | **Required.** A claim without its pointer is unverifiable | **None.** An address is something to look up, not something to read | +| Vocabulary | Whatever is exact | Whatever the product itself uses | +| Failure | A missing pointer | A sentence that cannot be answered without opening something | + +Apply one test to every sentence before it reaches the maintainer: + +> Would they have to look something up to understand this? + +If yes, it is an address, whatever form it takes — a file, a symbol, a record id, +a ledger code, a commit, a section number, a milestone or tier code, a schema +value printed as a category, or a term that exists only inside the +implementation. The test does not depend on the shape of the string or on the +language anything is written in, which is why it is the test and not a list. + +Two failure modes, and the second is the one that follows from over-correcting +the first: + +- **Addressed prose.** "The check in the catalog store was narrowed because the + gate validates on publish." Every noun is something to look up. +- **Emptied prose.** "A check was narrowed for a reason that turned out to be + wrong." Nothing to look up and nothing to decide on either. + +What is wanted is neither: say what the thing does for the product, and say it +with enough substance to be judged. "The client stopped checking anything but the +shape of a record, on the belief that publishing would catch the rest — and +publishing does not." The pointer to the file stays in the record, and is brought +out when the maintainer asks to audit rather than to decide. + +An address is never deleted to satisfy this. It moves. A finding whose evidence +was dropped to keep a sentence clean has been made worse, not better. + +Where a packet can be generated from records rather than composed, generate it. +A renderer that never reads an address cannot print one, which is worth more than +care taken one message at a time. Where it must be composed — most places — the +test above is the whole rule, and it is the agent's to apply. + One decision per packet. Several stacked in one message read as a status report and get answered as none of them. @@ -115,11 +169,36 @@ parks finished work behind a decision that could have been made on day one. Approving edits the change record, so refresh its checkpoint and re-read it before claiming. +Approval and permission to start are different decisions and the record holds +them separately. A maintainer who approves a framing and says the work is not to +begin yet — including one approving only so the bundle stops cluttering their +queue — is parking it: `wfctl work approve ... --park "<why>"`, or `wfctl work +park` afterwards. A parked bundle refuses every delivery claim before any other +gate is even read. Only `wfctl work release --attested "<their words>"` starts +it, and a release is never inferred: not from a truthful answer to an unrelated +question, and not from the condition that held it having cleared. The last time +one was inferred, six commits landed in three source repositories. + +Render the framing with `wfctl work ask <id>` rather than composing one. It +carries the four things approval fixes — what gets done, what deliberately does +not, what will make it finished, and in what order — and nothing else from a +record written for an agent. A section still holding the shipped template's own +words is reported as unwritten rather than read out as scope. + For significant work, record framing and completion decisions with `wfctl work -approve <id> --stage framing|completion --by human:<maintainer-id>`. That -command needs an interactive terminal, or an out-of-band `--token` matching -`WFCTL_APPROVAL_TOKEN`; it writes both the `maintainer_review` receipt and the -durable approval record the completion gate checks. Never hand-write +approve <id> --stage framing|completion --by human:<maintainer-id>`. It writes +both the `maintainer_review` receipt and the durable approval record the +completion gate checks. + +Pass `--attested "<their answer, word for word>" --session "<where they said +it>"`. That is the ordinary path, because the ordinary case is a maintainer who +already answered in conversation, and sending them to a second terminal to +retype a generated bundle id, a stage name and their own identity relocates the +same answer to a less convenient channel while recording no more than the +attestation does. A typed confirmation and an out-of-band `--token` matching +`WFCTL_APPROVAL_TOKEN` both remain, unequal and recorded as such: they prove a +command ran outside your own writing, which is a stronger record and the +maintainer's to ask for, never your default. Never hand-write `maintainer_review.status`, `by`, `at`, `method`, or `receipt`: a hand-written receipt fails verification. Existing explicit maintainer instructions may satisfy the framing decision, but still record it through the command; do not diff --git a/.claude/skills/manage-project-work/SKILL.md b/.claude/skills/manage-project-work/SKILL.md index 962a7cb..9508866 100644 --- a/.claude/skills/manage-project-work/SKILL.md +++ b/.claude/skills/manage-project-work/SKILL.md @@ -133,8 +133,10 @@ readable for compatibility. Framing and completion decisions are recorded with `wfctl work approve <id> --stage framing|completion --by human:<maintainer-id>`, never by editing -`maintainer_review` directly. The command requires an interactive terminal or -an out-of-band `--token`; a hand-written receipt fails the completion gate. +`maintainer_review` directly. Record what they answered in the session with +`--attested "<their words>" --session "<where>"`; a typed confirmation and an +out-of-band `--token` remain for a maintainer who wants a receipt you could not +have written. A hand-written receipt fails the completion gate. `changes/active/<id>/` is already the knowledge-side living record. There is no final dump into `raw/`. Completed closure moves the entire bundle intact to diff --git a/.claude/skills/manage-project-work/assets/work-spec.md b/.claude/skills/manage-project-work/assets/work-spec.md index 7cb17e3..5e36895 100644 --- a/.claude/skills/manage-project-work/assets/work-spec.md +++ b/.claude/skills/manage-project-work/assets/work-spec.md @@ -27,6 +27,8 @@ direction: knowledge_alignment: reviewed: [] conflicts: [] + # Set covered: false with a basis when no curated concept covers this work. + # An empty knowledge base is a supported state; an invented concept path is not. graph_evidence: queries: [] knowledge_promotion: diff --git a/.claude/skills/shape-project-direction/SKILL.md b/.claude/skills/shape-project-direction/SKILL.md index 318f24e..0280caa 100644 --- a/.claude/skills/shape-project-direction/SKILL.md +++ b/.claude/skills/shape-project-direction/SKILL.md @@ -19,12 +19,20 @@ charting or resuming. evidence leaves; unknown future implementation repositories are not guessed. 2. Run `wfctl work context <id> --stage wayfind` and `wfctl work status <id>`. Do not edit product source during this phase. -3. Name the destination first: what a finished map will make specifiable, for +3. Run `wfctl work repositories <id>` and read what every bound repository + declares about itself — the instructions its maintainer wrote in its own + agent file, and the skills installed only there. A map charted from the + centre is charted without them otherwise, and they are exactly the rules + that decide whether a route is possible in that repository. Invoke + `align-project-knowledge` in the same pass: fog is only fog until you check + whether the project already answered it. `wfctl work map finish` refuses + until every bound repository is accounted for. +4. Name the destination first: what a finished map will make specifiable, for whom, and the boundary it must not cross. -4. Explore breadth-first. Put a precise answerable question into a Wayfinder +5. Explore breadth-first. Put a precise answerable question into a Wayfinder issue. Put only still-unphraseable in-scope uncertainty into `map.md` fog. Put ruled-out work under out of scope. -5. Create currently visible blockers before their dependants with `wfctl work +6. Create currently visible blockers before their dependants with `wfctl work issue create --phase wayfinding --type research|prototype|grilling|task`. Use `wfctl work issue block|unblock` when later evidence changes an edge. Stop charting; do not resolve a normal issue diff --git a/.claude/skills/specify-project-change/SKILL.md b/.claude/skills/specify-project-change/SKILL.md index bee7312..2f250c6 100644 --- a/.claude/skills/specify-project-change/SKILL.md +++ b/.claude/skills/specify-project-change/SKILL.md @@ -33,6 +33,33 @@ the contract, invoke `analyze-with-graphify` in every relevant bound root, then open the actual source locations. Invoke `align-project-knowledge` before settling product or architecture meaning. +Work spanning more than one repository is shaped here, at the centre, because +only the centre sees them all at once. What the centre does not see is what each +repository declares about itself: the instructions its maintainer wrote in its +own agent file, and the skills installed only there. Those are specific and +binding — one repository opens with a plan file to read first, another calls its +navigation rule BINDING — and a session that never entered the checkout has no +way to know they exist. + +```sh +wfctl work repositories <id> +``` + +This prints them without leaving the centre. Read every one, then account for +each bound repository before asking for approval: + +```sh +wfctl work repositories <id> --read <repository> --note "<what its rules require of this work>" +wfctl work repositories <id> --untouched <repository> --reason "<why this work does not reach it>" +``` + +The hash of its instructions and the list of its own skills are taken from the +checkout, not from you, so the receipt binds to what was there; if the +repository changes its rules afterwards the receipt is reported stale rather +than quietly wrong. Framing approval and `wfctl work map finish` both refuse +until every bound repository is one or the other. Saying nothing is not a third +option, and neither is a note that only says the file was opened. + Update `change.md` with: - the problem and intended observable outcome; @@ -55,7 +82,8 @@ binds the current `change.md` rather than an earlier draft. ## Review and continue -Present a compact framing packet: outcome, scope, exclusions, decisions, +Present a compact framing packet — maintainer-facing, so the reader test in +`maintainer-review` governs every sentence: outcome, scope, exclusions, decisions, acceptance IDs, test seams, risks, and unresolved work. Record only explicit maintainer approval, and record it through the approval command rather than by editing the receipt: @@ -66,8 +94,10 @@ wfctl work approve <id> --stage framing \ --note "<what was approved>" ``` -The command requires an interactive terminal, or `--token` matching -`WFCTL_APPROVAL_TOKEN` in unattended use. A hand-written +Render the framing with `wfctl work ask <id>` and record their reply with +`--attested "<their words>" --session "<where>"`. A typed confirmation and a +`--token` matching `WFCTL_APPROVAL_TOKEN` remain available and are theirs to ask +for; do not send them to a second terminal by default. A hand-written `maintainer_review.framing` receipt fails verification. Approving rewrites `change.md`, so re-read it, refresh its review receipt, and refresh the checkpoint afterwards. diff --git a/.claude/skills/verify-project-work/SKILL.md b/.claude/skills/verify-project-work/SKILL.md index cb47054..9d1191e 100644 --- a/.claude/skills/verify-project-work/SKILL.md +++ b/.claude/skills/verify-project-work/SKILL.md @@ -66,7 +66,10 @@ from real work is better than none, and the cost of the shortcut is only hidden when nobody writes it down. Present acceptance results, engineering findings, checks, deviations, risks, -and knowledge delta as one completion review packet. Record the maintainer's +and knowledge delta as one completion review packet. It is maintainer-facing: +the reader test in `maintainer-review` applies, so acceptance is named by what +was asked for rather than by criterion id, and a deviation by what changed for +the product rather than by which file moved. Record the maintainer's explicit decision through the approval command; never write the receipt by hand: @@ -76,9 +79,10 @@ wfctl work approve <id> --stage completion \ --note "<what the maintainer accepted>" ``` -It requires an interactive terminal, or `--token` matching -`WFCTL_APPROVAL_TOKEN`. `wfctl work verify` rejects a receipt with no matching -approval record. Finish all semantic edits to `change.md`, then refresh its +Record what they answered with `--attested "<their words>" --session +"<where>"`; a typed confirmation and a `--token` matching `WFCTL_APPROVAL_TOKEN` +remain for a stronger record. `wfctl work verify` rejects a receipt with no +matching approval record. Finish all semantic edits to `change.md`, then refresh its checkpoint in review stage **before** recording the final hash receipt: ```sh diff --git a/.workflow/rules/execution-continuity.md b/.workflow/rules/execution-continuity.md index b758a25..202da1e 100644 --- a/.workflow/rules/execution-continuity.md +++ b/.workflow/rules/execution-continuity.md @@ -19,6 +19,14 @@ to ask; it is evidence the search was not done. acceptance criteria and proven by its terminal status. A finished plan item, a written summary, a passing subtask, and a refreshed checkpoint are progress. +**Check that a stop would lose nothing, rather than believing it.** `wfctl +resumable` answers that from the repository: a checkpoint describing a record +that has since changed, an open record that never had one, and work on disk no +checkpoint describes and no commit preserves are three different losses, and the +last is the one a basis digest structurally cannot see. A non-zero exit is not a +finding to report onward — refresh the checkpoint or commit, then end. The +maintainer asking you to wrap up is the failure this replaces. + **End a turn only when you are waiting on the maintainer.** Ending one hands control to them, so the test is what you are waiting for and not what you wrote. Announcing a next action and stopping is the visible half of this; the diff --git a/.workflow/rules/maintainer-review.md b/.workflow/rules/maintainer-review.md index 2a6c540..3f3edd3 100644 --- a/.workflow/rules/maintainer-review.md +++ b/.workflow/rules/maintainer-review.md @@ -19,6 +19,16 @@ Do not request review for freezing a clean Git raw scope, file accounting, QMD refresh, index or log maintenance, or source-backed drafts that keep their trust state honest. Raw intake itself never counts as evidence. +Reading is never a decision. Accounting for what each bound repository declares +about itself, running Graphify in every bound root, checking curated knowledge +before settling meaning — a gate refusing until those are done is telling the +agent to go and do them, not handing the agent a question. Nothing about them +reaches the maintainer: not as a request, not as a status line, and not as an +apology for the delay. The one thing that does reach them is a product +consequence found while reading — a rule in one repository that makes the +proposed route impossible there is a decision, and it is put to them as what the +product must do instead, never as the file it was found in. + Present a compact review packet containing the exact decision, evidence, conflicts, recommendation, and requested response. Record approve, correct, or defer. Ask one focused question at a time. Silence is not approval. @@ -88,6 +98,50 @@ most once. If a decision genuinely cannot be stated without engineering vocabulary, that is a finding rather than a licence: the capability has no product-level description yet, and recording that gap is part of the answer. +## The line, and how to tell which side you are on + +Everything this workflow produces is written for one of two readers, and the two +are not styles of the same text. They are different texts. + +| | Written for the record | Written for the maintainer | +| --- | --- | --- | +| Reader | The next agent, the compiler, an auditor | A person deciding about their product | +| Addresses | **Required.** A claim without its pointer is unverifiable | **None.** An address is something to look up, not something to read | +| Vocabulary | Whatever is exact | Whatever the product itself uses | +| Failure | A missing pointer | A sentence that cannot be answered without opening something | + +Apply one test to every sentence before it reaches the maintainer: + +> Would they have to look something up to understand this? + +If yes, it is an address, whatever form it takes — a file, a symbol, a record id, +a ledger code, a commit, a section number, a milestone or tier code, a schema +value printed as a category, or a term that exists only inside the +implementation. The test does not depend on the shape of the string or on the +language anything is written in, which is why it is the test and not a list. + +Two failure modes, and the second is the one that follows from over-correcting +the first: + +- **Addressed prose.** "The check in the catalog store was narrowed because the + gate validates on publish." Every noun is something to look up. +- **Emptied prose.** "A check was narrowed for a reason that turned out to be + wrong." Nothing to look up and nothing to decide on either. + +What is wanted is neither: say what the thing does for the product, and say it +with enough substance to be judged. "The client stopped checking anything but the +shape of a record, on the belief that publishing would catch the rest — and +publishing does not." The pointer to the file stays in the record, and is brought +out when the maintainer asks to audit rather than to decide. + +An address is never deleted to satisfy this. It moves. A finding whose evidence +was dropped to keep a sentence clean has been made worse, not better. + +Where a packet can be generated from records rather than composed, generate it. +A renderer that never reads an address cannot print one, which is worth more than +care taken one message at a time. Where it must be composed — most places — the +test above is the whole rule, and it is the agent's to apply. + One decision per packet. Several stacked in one message read as a status report and get answered as none of them. @@ -115,11 +169,36 @@ parks finished work behind a decision that could have been made on day one. Approving edits the change record, so refresh its checkpoint and re-read it before claiming. +Approval and permission to start are different decisions and the record holds +them separately. A maintainer who approves a framing and says the work is not to +begin yet — including one approving only so the bundle stops cluttering their +queue — is parking it: `wfctl work approve ... --park "<why>"`, or `wfctl work +park` afterwards. A parked bundle refuses every delivery claim before any other +gate is even read. Only `wfctl work release --attested "<their words>"` starts +it, and a release is never inferred: not from a truthful answer to an unrelated +question, and not from the condition that held it having cleared. The last time +one was inferred, six commits landed in three source repositories. + +Render the framing with `wfctl work ask <id>` rather than composing one. It +carries the four things approval fixes — what gets done, what deliberately does +not, what will make it finished, and in what order — and nothing else from a +record written for an agent. A section still holding the shipped template's own +words is reported as unwritten rather than read out as scope. + For significant work, record framing and completion decisions with `wfctl work -approve <id> --stage framing|completion --by human:<maintainer-id>`. That -command needs an interactive terminal, or an out-of-band `--token` matching -`WFCTL_APPROVAL_TOKEN`; it writes both the `maintainer_review` receipt and the -durable approval record the completion gate checks. Never hand-write +approve <id> --stage framing|completion --by human:<maintainer-id>`. It writes +both the `maintainer_review` receipt and the durable approval record the +completion gate checks. + +Pass `--attested "<their answer, word for word>" --session "<where they said +it>"`. That is the ordinary path, because the ordinary case is a maintainer who +already answered in conversation, and sending them to a second terminal to +retype a generated bundle id, a stage name and their own identity relocates the +same answer to a less convenient channel while recording no more than the +attestation does. A typed confirmation and an out-of-band `--token` matching +`WFCTL_APPROVAL_TOKEN` both remain, unequal and recorded as such: they prove a +command ran outside your own writing, which is a stronger record and the +maintainer's to ask for, never your default. Never hand-write `maintainer_review.status`, `by`, `at`, `method`, or `receipt`: a hand-written receipt fails verification. Existing explicit maintainer instructions may satisfy the framing decision, but still record it through the command; do not diff --git a/.workflow/runtime/guard-stop.mjs b/.workflow/runtime/guard-stop.mjs index a04eab3..92afa1a 100644 --- a/.workflow/runtime/guard-stop.mjs +++ b/.workflow/runtime/guard-stop.mjs @@ -54,6 +54,13 @@ function main() { } const cwd = input.cwd || process.cwd(); + // Turned off deliberately. The switch is a marker file rather than the absence + // of the settings entry, because an upgrade reinstalls the entry and would + // silently undo the maintainer's choice. + if (disabled(cwd)) { + allow(); + return; + } const report = readState(cwd); if (!report) { allow(); @@ -129,6 +136,15 @@ function main() { process.exit(0); } +function disabled(cwd) { + try { + readFileSync(join(cwd, ".workflow/current/hooks/stop-guard.disabled"), "utf8"); + return true; + } catch { + return false; + } +} + function readState(cwd) { const result = spawnSync("wfctl", ["brief", "--json"], { cwd, @@ -224,6 +240,10 @@ function reason(message, awaiting) { "work continues by itself\" is not that line — nothing continues once the", "turn is over.", "", + "Either way, run `wfctl resumable` before you end. It answers whether", + "stopping now would lose anything, and a non-zero exit means refresh the", + "checkpoint or commit first rather than reporting the problem onward.", + "", "This check returns while each turn moves the repository and releases on the", "first turn that does not. Do not acknowledge it, agree with it, explain", "yourself, or answer with an empty turn.", diff --git a/.workflow/state.json b/.workflow/state.json index 4f9e1c9..64ffcd0 100644 --- a/.workflow/state.json +++ b/.workflow/state.json @@ -7,10 +7,10 @@ "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" }, ".claude/rules/execution-continuity.md": { - "sha256": "2f156cedf6773cbb17357d755d404141936f5acc4fd7a4a33f0fc2a3068b9d8b" + "sha256": "e31b03c8c84eb1b385c1539141cf7d24085f317027fe1fce873605978c26d5a2" }, ".claude/rules/maintainer-review.md": { - "sha256": "505b43c870ded722211c985164bdaf8e32c7cf6cbd9a3dc146e30cf6caa400d5" + "sha256": "1fb9041049f99fa2042c292518b52f19c3c7f632b97da20a2704948bd6fbc8c8" }, ".claude/rules/project-work-bundles.md": { "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" @@ -28,10 +28,10 @@ "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" }, ".workflow/rules/execution-continuity.md": { - "sha256": "2f156cedf6773cbb17357d755d404141936f5acc4fd7a4a33f0fc2a3068b9d8b" + "sha256": "e31b03c8c84eb1b385c1539141cf7d24085f317027fe1fce873605978c26d5a2" }, ".workflow/rules/maintainer-review.md": { - "sha256": "505b43c870ded722211c985164bdaf8e32c7cf6cbd9a3dc146e30cf6caa400d5" + "sha256": "1fb9041049f99fa2042c292518b52f19c3c7f632b97da20a2704948bd6fbc8c8" }, ".workflow/rules/project-work-bundles.md": { "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" @@ -46,7 +46,7 @@ "sha256": "0bb95f5bc34cb2ee9c3c453a171cd36f60d8c7d4eeb283ea07e8ad91d5b3ef93" }, ".workflow/runtime/guard-stop.mjs": { - "sha256": "0cb903b30176d722d0cc2c3cf5f215d989b3d03204d654bdde6111ea283f2e18" + "sha256": "1c83e1c15e047609016ae24c7966d9167856d64fd7c92b95690bd1c848dcaab0" }, ".workflow/runtime/idle-guard.sh": { "sha256": "8a132664ff43cc732e4da14b33e7b72f89e5facbb26169b3135eac2c64390da2" diff --git a/AGENTS.md b/AGENTS.md index ccc1034..10d4920 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,24 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und index, ranking, and snippets as navigation only; verify by direct reading and authoritative sources. - Present bounded review packets and require explicit maintainer decisions at the gates defined by the workflow. +- Approving a framing settles what the work is, never that it begins. When the + maintainer approves and says starting is premature — including approving only + to clear their queue — record both: `wfctl work approve ... --park "<why>"`. + A parked bundle refuses every delivery claim, and only `wfctl work release + --attested "<their words>"` starts it. Never infer a release from an answer to + a different question, and never from the reconstruction or blocker that held + it having since cleared: the condition ending is not the same as being told to + go. +- Put a framing to the maintainer with `wfctl work ask`, which renders the four + things approval fixes — what gets done, what deliberately does not, what makes + it finished, in what order — and nothing else from a record written for you. - Record framing and completion approvals with `wfctl work approve`, never by - editing `maintainer_review`. The maintainer confirms in their own terminal; - a hand-written approval receipt fails verification. + editing `maintainer_review`; a hand-written receipt fails verification. Pass + `--attested "<their answer, word for word>" --session "<where they said it>"` + when they answered in the session, which is the ordinary case. Do not send them + to a second terminal: retyping a generated bundle id, a stage name and their + own identity records no decision the attestation does not. A typed confirmation + or `--token` remains available and is theirs to ask for, never your default. - Ask one material question at a time, include a recommendation, and update the durable record before continuing. - Write every maintainer-facing message in the product's own language, not only @@ -50,18 +65,30 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und different choices remain, present their human meaning, evidence, and recommendation; after the maintainer chooses, execute the corresponding commands yourself. +- Before ending a turn, run `wfctl resumable`. It answers, from the repository + rather than from your memory of it, whether stopping now would lose anything: + a checkpoint describing a record that has since changed, an open record that + never had one, or work on disk no checkpoint describes and no commit preserves. + A non-zero exit is not a report to pass on — refresh the checkpoint or commit, + then end. The maintainer should never have to ask you to wrap up. - For significant multi-turn work, create the central bundle early. After every material maintainer turn or agent investigation cycle, preserve consequential new understanding in the owning record's broad `Discovery ledger`, update the affected semantic state, and refresh its structured checkpoint last. The preservation trigger is consequence of information - loss, not a fixed category of findings. + loss, not a fixed category of findings. Small jobs noticed along the way go in + the checkpoint's own list — `--todo-add`, cleared with `--todo-drop` — which is + neither a blocker nor the next action, survives a checkpoint that says nothing + about it, and reaches the next session through the brief. Anything you intend + to "come back to" and leave only in prose is lost with the context holding it. - Run `wfctl brief --json` before anything else in a session, unless a session brief was already delivered as context, in which case use that one. It is the authoritative current state of this repository: signals are observed facts and capabilities are derived from them. Do not rediscover that state by scanning - records, and do not read the list back to the maintainer. Compose one short - orientation from it — what exists, what is in progress, what waits on them — + records, and do not read the list back to the maintainer. Every open record + carries a `*.resume` signal holding where its work stopped and the next action + it named; that is the resume state, so read it rather than reconstructing one. + Compose one short orientation from it — what exists, what is in progress, what waits on them — and offer the operations reported available. For a blocked capability, name what would unblock it instead of starting it. The brief never starts work; a signal with `awaits: maintainer` is a question for them, not a task for you. diff --git a/skills-lock.json b/skills-lock.json index e1e8f6a..52be793 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -39,7 +39,7 @@ "manage-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "b83becc7b3d6bfb4b1d074c48c53b5d551bca27107a175a804de1206f1c6cdce" + "computedHash": "3c79d2aad3bd880c53b7b4026abf8f229e5b0038a0745ef5b1dfb8586cdc1e4d" }, "qmd": { "source": "/Users/lukachisama/.bun/install/global/node_modules/@tobilu/qmd/skills/qmd", @@ -54,12 +54,12 @@ "shape-project-direction": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "7014dda9496a2b403aa027863311f0e1a7c6a038e3d4498d4ec61dc82035ead7" + "computedHash": "7e0b6c9535c4d1faf95c340b1c1b3b9c8e68d3e9dbc63ba77b56df46b437e192" }, "specify-project-change": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "274b797fc56704ac5f6c83bef60a438f3edbafa081806d1c0608ecc37b2ce5af" + "computedHash": "acbe1a8faad0aa2287169bc51924fe28f3c9f3200e4939b9aa508546cbff301e" }, "split-project-change": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", @@ -74,7 +74,7 @@ "verify-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "8772c9dcc74bc926ac47bf9aa6933f29cb74e2b94cc7c1a4ceecd7f7a27cf383" + "computedHash": "39f6891a942dab859ff17efca8e7d953037530153acb183f61151ec855c087e6" } } } From 4cb668199d1f739d687e8e99ee5025b221b5f342 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 12:54:26 +0300 Subject: [PATCH 078/124] refactor(tx-manifest): put each module behind its own boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package was one directory holding 30 sources, 23 tests beside them and a global types.ts. The repository's own file-structure rule names that shape directly, and the accounting made at framing missed it because it read the skill's description rather than the skill. The layout follows the import graph rather than topic. document/ is the format layer, chain/ is what the chain says, evaluation/ resolves expressions, covenants/ derives and compares addresses, request/ is what the site sent, and confirmation/ and review/ are entrypoints owning the files only they use — bytes inside chain, provenance inside confirmation, coinSelection inside review, each of which already had exactly one consumer while sitting in public. The public surface drops from about thirty-five exports to twenty, which is what anything outside actually imports, ordered by the four steps a wallet takes rather than alphabetically. The import path does not change. Found while moving, and worth more than the move: stateless.test.ts walked one directory. Its nine standing structural checks would have gone on passing over two files out of thirty, reporting clean because they had stopped looking. The walk is recursive now, the import check resolves a specifier instead of matching its shape, and a first assertion fails if the sweep ever narrows again. 508 tests, gate green, extension builds. --- packages/tx-manifest/src/{ => chain}/bytes.ts | 0 .../src/{ => chain}/chainRead.test.ts | 2 +- .../tx-manifest/src/{ => chain}/chainRead.ts | 0 .../src/{ => chain}/inputGuard.test.ts | 0 .../tx-manifest/src/{ => chain}/inputGuard.ts | 0 .../src/{ => chain}/spentInputs.ts | 0 .../tx-manifest/src/{ => chain}/txOut.test.ts | 2 +- packages/tx-manifest/src/{ => chain}/txOut.ts | 0 .../index.ts} | 6 +- .../src/{ => confirmation}/provenance.test.ts | 0 .../src/{ => confirmation}/provenance.ts | 0 packages/tx-manifest/src/corpus.test.ts | 6 +- .../src/{ => covenants}/compileParams.test.ts | 2 +- .../src/{ => covenants}/compileParams.ts | 4 +- .../src/{ => covenants}/computed.test.ts | 2 +- .../src/{ => covenants}/computed.ts | 6 +- .../src/{ => covenants}/covenant.test.ts | 4 +- .../src/{ => covenants}/covenant.ts | 8 +-- .../src/{ => covenants}/covenantHash.test.ts | 0 .../src/{ => covenants}/covenantHash.ts | 0 .../tx-manifest/src/{ => document}/json.ts | 0 .../src/{ => document}/normalise.test.ts | 4 +- .../src/{ => document}/normalise.ts | 0 .../src/{ => document}/references.test.ts | 0 .../src/{ => document}/references.ts | 0 .../src/{ => document}/refuse.test.ts | 0 .../tx-manifest/src/{ => document}/refuse.ts | 0 .../src/{ => document}/registry.test.ts | 2 +- .../src/{ => document}/registry.ts | 0 .../tx-manifest/src/{ => document}/sites.ts | 0 .../src/{ => evaluation}/encode.test.ts | 0 .../src/{ => evaluation}/encode.ts | 2 +- .../src/{ => evaluation}/evaluate.test.ts | 2 +- .../src/{ => evaluation}/evaluate.ts | 8 ++- .../src/{ => evaluation}/inputRules.test.ts | 4 +- .../src/{ => evaluation}/inputRules.ts | 6 +- .../src/{ => evaluation}/plan.test.ts | 6 +- .../tx-manifest/src/{ => evaluation}/plan.ts | 6 +- .../src/{ => evaluation}/validate.ts | 6 +- packages/tx-manifest/src/index.ts | 47 ++++++------ .../src/{types.ts => request/request.ts} | 0 .../src/{ => request}/requirements.test.ts | 8 +-- .../src/{ => request}/requirements.ts | 10 +-- .../src/{ => request}/validation.test.ts | 2 +- .../src/{ => request}/validation.ts | 2 +- .../src/{ => review}/coinSelection.test.ts | 0 .../src/{ => review}/coinSelection.ts | 0 .../{review.test.ts => review/index.test.ts} | 14 ++-- .../src/{review.ts => review/index.ts} | 40 ++++++----- packages/tx-manifest/src/stateless.test.ts | 71 ++++++++++++++++--- 50 files changed, 172 insertions(+), 110 deletions(-) rename packages/tx-manifest/src/{ => chain}/bytes.ts (100%) rename packages/tx-manifest/src/{ => chain}/chainRead.test.ts (99%) rename packages/tx-manifest/src/{ => chain}/chainRead.ts (100%) rename packages/tx-manifest/src/{ => chain}/inputGuard.test.ts (100%) rename packages/tx-manifest/src/{ => chain}/inputGuard.ts (100%) rename packages/tx-manifest/src/{ => chain}/spentInputs.ts (100%) rename packages/tx-manifest/src/{ => chain}/txOut.test.ts (98%) rename packages/tx-manifest/src/{ => chain}/txOut.ts (100%) rename packages/tx-manifest/src/{confirmation.ts => confirmation/index.ts} (96%) rename packages/tx-manifest/src/{ => confirmation}/provenance.test.ts (100%) rename packages/tx-manifest/src/{ => confirmation}/provenance.ts (100%) rename packages/tx-manifest/src/{ => covenants}/compileParams.test.ts (98%) rename packages/tx-manifest/src/{ => covenants}/compileParams.ts (98%) rename packages/tx-manifest/src/{ => covenants}/computed.test.ts (99%) rename packages/tx-manifest/src/{ => covenants}/computed.ts (97%) rename packages/tx-manifest/src/{ => covenants}/covenant.test.ts (98%) rename packages/tx-manifest/src/{ => covenants}/covenant.ts (96%) rename packages/tx-manifest/src/{ => covenants}/covenantHash.test.ts (100%) rename packages/tx-manifest/src/{ => covenants}/covenantHash.ts (100%) rename packages/tx-manifest/src/{ => document}/json.ts (100%) rename packages/tx-manifest/src/{ => document}/normalise.test.ts (97%) rename packages/tx-manifest/src/{ => document}/normalise.ts (100%) rename packages/tx-manifest/src/{ => document}/references.test.ts (100%) rename packages/tx-manifest/src/{ => document}/references.ts (100%) rename packages/tx-manifest/src/{ => document}/refuse.test.ts (100%) rename packages/tx-manifest/src/{ => document}/refuse.ts (100%) rename packages/tx-manifest/src/{ => document}/registry.test.ts (99%) rename packages/tx-manifest/src/{ => document}/registry.ts (100%) rename packages/tx-manifest/src/{ => document}/sites.ts (100%) rename packages/tx-manifest/src/{ => evaluation}/encode.test.ts (100%) rename packages/tx-manifest/src/{ => evaluation}/encode.ts (99%) rename packages/tx-manifest/src/{ => evaluation}/evaluate.test.ts (98%) rename packages/tx-manifest/src/{ => evaluation}/evaluate.ts (98%) rename packages/tx-manifest/src/{ => evaluation}/inputRules.test.ts (96%) rename packages/tx-manifest/src/{ => evaluation}/inputRules.ts (95%) rename packages/tx-manifest/src/{ => evaluation}/plan.test.ts (97%) rename packages/tx-manifest/src/{ => evaluation}/plan.ts (96%) rename packages/tx-manifest/src/{ => evaluation}/validate.ts (92%) rename packages/tx-manifest/src/{types.ts => request/request.ts} (100%) rename packages/tx-manifest/src/{ => request}/requirements.test.ts (96%) rename packages/tx-manifest/src/{ => request}/requirements.ts (94%) rename packages/tx-manifest/src/{ => request}/validation.test.ts (96%) rename packages/tx-manifest/src/{ => request}/validation.ts (96%) rename packages/tx-manifest/src/{ => review}/coinSelection.test.ts (100%) rename packages/tx-manifest/src/{ => review}/coinSelection.ts (100%) rename packages/tx-manifest/src/{review.test.ts => review/index.test.ts} (97%) rename packages/tx-manifest/src/{review.ts => review/index.ts} (93%) diff --git a/packages/tx-manifest/src/bytes.ts b/packages/tx-manifest/src/chain/bytes.ts similarity index 100% rename from packages/tx-manifest/src/bytes.ts rename to packages/tx-manifest/src/chain/bytes.ts diff --git a/packages/tx-manifest/src/chainRead.test.ts b/packages/tx-manifest/src/chain/chainRead.test.ts similarity index 99% rename from packages/tx-manifest/src/chainRead.test.ts rename to packages/tx-manifest/src/chain/chainRead.test.ts index 79b83a9..66a036c 100644 --- a/packages/tx-manifest/src/chainRead.test.ts +++ b/packages/tx-manifest/src/chain/chainRead.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import transactions from "./__fixtures__/testnet-transactions.json"; +import transactions from "../__fixtures__/testnet-transactions.json"; import { createEsploraFeeRateReader, createEsploraTxOutReader } from "./chainRead"; // The transaction read asks for /tx/:txid/raw and gets consensus bytes back. The fee read is diff --git a/packages/tx-manifest/src/chainRead.ts b/packages/tx-manifest/src/chain/chainRead.ts similarity index 100% rename from packages/tx-manifest/src/chainRead.ts rename to packages/tx-manifest/src/chain/chainRead.ts diff --git a/packages/tx-manifest/src/inputGuard.test.ts b/packages/tx-manifest/src/chain/inputGuard.test.ts similarity index 100% rename from packages/tx-manifest/src/inputGuard.test.ts rename to packages/tx-manifest/src/chain/inputGuard.test.ts diff --git a/packages/tx-manifest/src/inputGuard.ts b/packages/tx-manifest/src/chain/inputGuard.ts similarity index 100% rename from packages/tx-manifest/src/inputGuard.ts rename to packages/tx-manifest/src/chain/inputGuard.ts diff --git a/packages/tx-manifest/src/spentInputs.ts b/packages/tx-manifest/src/chain/spentInputs.ts similarity index 100% rename from packages/tx-manifest/src/spentInputs.ts rename to packages/tx-manifest/src/chain/spentInputs.ts diff --git a/packages/tx-manifest/src/txOut.test.ts b/packages/tx-manifest/src/chain/txOut.test.ts similarity index 98% rename from packages/tx-manifest/src/txOut.test.ts rename to packages/tx-manifest/src/chain/txOut.test.ts index 44f5841..027ce8f 100644 --- a/packages/tx-manifest/src/txOut.test.ts +++ b/packages/tx-manifest/src/chain/txOut.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import transactions from "./__fixtures__/testnet-transactions.json"; +import transactions from "../__fixtures__/testnet-transactions.json"; import { txOutAt } from "./txOut"; /** diff --git a/packages/tx-manifest/src/txOut.ts b/packages/tx-manifest/src/chain/txOut.ts similarity index 100% rename from packages/tx-manifest/src/txOut.ts rename to packages/tx-manifest/src/chain/txOut.ts diff --git a/packages/tx-manifest/src/confirmation.ts b/packages/tx-manifest/src/confirmation/index.ts similarity index 96% rename from packages/tx-manifest/src/confirmation.ts rename to packages/tx-manifest/src/confirmation/index.ts index bf7d477..a12abb5 100644 --- a/packages/tx-manifest/src/confirmation.ts +++ b/packages/tx-manifest/src/confirmation/index.ts @@ -1,7 +1,7 @@ -import { asRecord } from "./json"; -import type { NormalisedAction, NormalisedManifest } from "./normalise"; +import { asRecord } from "../document/json"; +import type { NormalisedAction, NormalisedManifest } from "../document/normalise"; +import type { ManifestReview } from "../review"; import { computed, fromSite, map, type Origin, type Provenanced, verified } from "./provenance"; -import type { ManifestReview } from "./review"; /** One asset's movement in or out of the wallet, as a person would read it. */ export type NetEffect = { diff --git a/packages/tx-manifest/src/provenance.test.ts b/packages/tx-manifest/src/confirmation/provenance.test.ts similarity index 100% rename from packages/tx-manifest/src/provenance.test.ts rename to packages/tx-manifest/src/confirmation/provenance.test.ts diff --git a/packages/tx-manifest/src/provenance.ts b/packages/tx-manifest/src/confirmation/provenance.ts similarity index 100% rename from packages/tx-manifest/src/provenance.ts rename to packages/tx-manifest/src/confirmation/provenance.ts diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index c25a769..2a2dd83 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -8,9 +8,9 @@ import lendingV3 from "./__fixtures__/lending_v3.manifest.json"; import p2pkGrouped from "./__fixtures__/p2pk-grouped.manifest.json"; import p2pk from "./__fixtures__/p2pk.manifest.json"; import zeroconf from "./__fixtures__/zeroconf.manifest.json"; -import { findAction, normaliseManifest } from "./normalise"; -import { refuseUnsupported } from "./refuse"; -import { ignored, inspectConstructs, loadBearing } from "./registry"; +import { findAction, normaliseManifest } from "./document/normalise"; +import { refuseUnsupported } from "./document/refuse"; +import { ignored, inspectConstructs, loadBearing } from "./document/registry"; // The seven published manifests, vendored byte for byte from txmanifest-wallet at // 7d56516a1a1e44a586f25d45a34619c3953758dd. They are the only thing that can answer what diff --git a/packages/tx-manifest/src/compileParams.test.ts b/packages/tx-manifest/src/covenants/compileParams.test.ts similarity index 98% rename from packages/tx-manifest/src/compileParams.test.ts rename to packages/tx-manifest/src/covenants/compileParams.test.ts index f312a31..f13f1e9 100644 --- a/packages/tx-manifest/src/compileParams.test.ts +++ b/packages/tx-manifest/src/covenants/compileParams.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; +import type { ReferenceScope } from "../document/references"; import { resolveCompileParams } from "./compileParams"; -import type { ReferenceScope } from "./references"; // The wiring and the declared types below are the published p2pk manifest's own: // `Pay` writes a destination with compile_params {"PUB_KEY": "params.pubkey"}, and the diff --git a/packages/tx-manifest/src/compileParams.ts b/packages/tx-manifest/src/covenants/compileParams.ts similarity index 98% rename from packages/tx-manifest/src/compileParams.ts rename to packages/tx-manifest/src/covenants/compileParams.ts index d3193a3..f623288 100644 --- a/packages/tx-manifest/src/compileParams.ts +++ b/packages/tx-manifest/src/covenants/compileParams.ts @@ -1,5 +1,5 @@ -import type { NormalisationNote } from "./normalise"; -import { type ReferenceScope, resolveReference } from "./references"; +import type { NormalisationNote } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; /** * A contract's compile-time parameters, in SimplicityHL's own argument JSON shape. diff --git a/packages/tx-manifest/src/computed.test.ts b/packages/tx-manifest/src/covenants/computed.test.ts similarity index 99% rename from packages/tx-manifest/src/computed.test.ts rename to packages/tx-manifest/src/covenants/computed.test.ts index 6dcb70d..7b3dda2 100644 --- a/packages/tx-manifest/src/computed.test.ts +++ b/packages/tx-manifest/src/covenants/computed.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { normaliseManifest } from "../document/normalise"; import { COVENANT_HASH_SEED, ITERATION_BOUND, resolveComputedParams } from "./computed"; -import { normaliseManifest } from "./normalise"; // A covenant's compile parameters can hold another covenant's script hash, and that other // covenant's parameters can hold the first's — which the reference implementation resolves diff --git a/packages/tx-manifest/src/computed.ts b/packages/tx-manifest/src/covenants/computed.ts similarity index 97% rename from packages/tx-manifest/src/computed.ts rename to packages/tx-manifest/src/covenants/computed.ts index 3628d22..4b371da 100644 --- a/packages/tx-manifest/src/computed.ts +++ b/packages/tx-manifest/src/covenants/computed.ts @@ -1,7 +1,7 @@ +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; import { resolveCompileParams } from "./compileParams"; -import { asArray, asRecord } from "./json"; -import type { NormalisationNote, NormalisedAction } from "./normalise"; -import type { ReferenceScope } from "./references"; /** * What the first round of iteration stands a covenant hash on. diff --git a/packages/tx-manifest/src/covenant.test.ts b/packages/tx-manifest/src/covenants/covenant.test.ts similarity index 98% rename from packages/tx-manifest/src/covenant.test.ts rename to packages/tx-manifest/src/covenants/covenant.test.ts index b117c34..c7bc21f 100644 --- a/packages/tx-manifest/src/covenant.test.ts +++ b/packages/tx-manifest/src/covenants/covenant.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import { normaliseManifest } from "../document/normalise"; import { covenantMatchesChain, deriveCovenantAddress } from "./covenant"; -import { normaliseManifest } from "./normalise"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const SOURCE_PATH = "./p2pk.simf"; diff --git a/packages/tx-manifest/src/covenant.ts b/packages/tx-manifest/src/covenants/covenant.ts similarity index 96% rename from packages/tx-manifest/src/covenant.ts rename to packages/tx-manifest/src/covenants/covenant.ts index 262d7d9..81b0fb6 100644 --- a/packages/tx-manifest/src/covenant.ts +++ b/packages/tx-manifest/src/covenants/covenant.ts @@ -1,8 +1,8 @@ +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import { encodeLeafItem } from "../evaluation/encode"; import { resolveCompileParams } from "./compileParams"; -import { encodeLeafItem } from "./encode"; -import { asArray, asRecord } from "./json"; -import type { NormalisationNote, NormalisedManifest } from "./normalise"; -import type { ReferenceScope } from "./references"; /** * Compiles a covenant and reports the address it derives. diff --git a/packages/tx-manifest/src/covenantHash.test.ts b/packages/tx-manifest/src/covenants/covenantHash.test.ts similarity index 100% rename from packages/tx-manifest/src/covenantHash.test.ts rename to packages/tx-manifest/src/covenants/covenantHash.test.ts diff --git a/packages/tx-manifest/src/covenantHash.ts b/packages/tx-manifest/src/covenants/covenantHash.ts similarity index 100% rename from packages/tx-manifest/src/covenantHash.ts rename to packages/tx-manifest/src/covenants/covenantHash.ts diff --git a/packages/tx-manifest/src/json.ts b/packages/tx-manifest/src/document/json.ts similarity index 100% rename from packages/tx-manifest/src/json.ts rename to packages/tx-manifest/src/document/json.ts diff --git a/packages/tx-manifest/src/normalise.test.ts b/packages/tx-manifest/src/document/normalise.test.ts similarity index 97% rename from packages/tx-manifest/src/normalise.test.ts rename to packages/tx-manifest/src/document/normalise.test.ts index a766aed..bb80542 100644 --- a/packages/tx-manifest/src/normalise.test.ts +++ b/packages/tx-manifest/src/document/normalise.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import groupedManifest from "./__fixtures__/p2pk-grouped.manifest.json"; -import flatManifest from "./__fixtures__/p2pk.manifest.json"; +import groupedManifest from "../__fixtures__/p2pk-grouped.manifest.json"; +import flatManifest from "../__fixtures__/p2pk.manifest.json"; import { findAction, normaliseInstance, normaliseManifest } from "./normalise"; // The flat fixture is the published p2pk manifest at txmanifest-wallet diff --git a/packages/tx-manifest/src/normalise.ts b/packages/tx-manifest/src/document/normalise.ts similarity index 100% rename from packages/tx-manifest/src/normalise.ts rename to packages/tx-manifest/src/document/normalise.ts diff --git a/packages/tx-manifest/src/references.test.ts b/packages/tx-manifest/src/document/references.test.ts similarity index 100% rename from packages/tx-manifest/src/references.test.ts rename to packages/tx-manifest/src/document/references.test.ts diff --git a/packages/tx-manifest/src/references.ts b/packages/tx-manifest/src/document/references.ts similarity index 100% rename from packages/tx-manifest/src/references.ts rename to packages/tx-manifest/src/document/references.ts diff --git a/packages/tx-manifest/src/refuse.test.ts b/packages/tx-manifest/src/document/refuse.test.ts similarity index 100% rename from packages/tx-manifest/src/refuse.test.ts rename to packages/tx-manifest/src/document/refuse.test.ts diff --git a/packages/tx-manifest/src/refuse.ts b/packages/tx-manifest/src/document/refuse.ts similarity index 100% rename from packages/tx-manifest/src/refuse.ts rename to packages/tx-manifest/src/document/refuse.ts diff --git a/packages/tx-manifest/src/registry.test.ts b/packages/tx-manifest/src/document/registry.test.ts similarity index 99% rename from packages/tx-manifest/src/registry.test.ts rename to packages/tx-manifest/src/document/registry.test.ts index 3d8693d..ddde785 100644 --- a/packages/tx-manifest/src/registry.test.ts +++ b/packages/tx-manifest/src/document/registry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import flatManifest from "./__fixtures__/p2pk.manifest.json"; +import flatManifest from "../__fixtures__/p2pk.manifest.json"; import { normaliseManifest } from "./normalise"; import { type ConstructFinding, ignored, inspectConstructs, loadBearing } from "./registry"; diff --git a/packages/tx-manifest/src/registry.ts b/packages/tx-manifest/src/document/registry.ts similarity index 100% rename from packages/tx-manifest/src/registry.ts rename to packages/tx-manifest/src/document/registry.ts diff --git a/packages/tx-manifest/src/sites.ts b/packages/tx-manifest/src/document/sites.ts similarity index 100% rename from packages/tx-manifest/src/sites.ts rename to packages/tx-manifest/src/document/sites.ts diff --git a/packages/tx-manifest/src/encode.test.ts b/packages/tx-manifest/src/evaluation/encode.test.ts similarity index 100% rename from packages/tx-manifest/src/encode.test.ts rename to packages/tx-manifest/src/evaluation/encode.test.ts diff --git a/packages/tx-manifest/src/encode.ts b/packages/tx-manifest/src/evaluation/encode.ts similarity index 99% rename from packages/tx-manifest/src/encode.ts rename to packages/tx-manifest/src/evaluation/encode.ts index 872a00f..c9fc048 100644 --- a/packages/tx-manifest/src/encode.ts +++ b/packages/tx-manifest/src/evaluation/encode.ts @@ -1,4 +1,4 @@ -import { asArray, asRecord } from "./json"; +import { asArray, asRecord } from "../document/json"; export type EncodedBytes = { hex: string; ok: true } | { ok: false; reason: string }; diff --git a/packages/tx-manifest/src/evaluate.test.ts b/packages/tx-manifest/src/evaluation/evaluate.test.ts similarity index 98% rename from packages/tx-manifest/src/evaluate.test.ts rename to packages/tx-manifest/src/evaluation/evaluate.test.ts index 290ca1b..d6d5bf3 100644 --- a/packages/tx-manifest/src/evaluate.test.ts +++ b/packages/tx-manifest/src/evaluation/evaluate.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; +import type { ReferenceScope } from "../document/references"; import { evaluateExpression } from "./evaluate"; -import type { ReferenceScope } from "./references"; // The format defines no arithmetic. What real manifests were authored against is the // reference implementation's behaviour, recorded in the change bundle's inventory: it diff --git a/packages/tx-manifest/src/evaluate.ts b/packages/tx-manifest/src/evaluation/evaluate.ts similarity index 98% rename from packages/tx-manifest/src/evaluate.ts rename to packages/tx-manifest/src/evaluation/evaluate.ts index 5a36323..07707f4 100644 --- a/packages/tx-manifest/src/evaluate.ts +++ b/packages/tx-manifest/src/evaluation/evaluate.ts @@ -1,5 +1,9 @@ -import type { NormalisationNote } from "./normalise"; -import { type ReferenceScope, type ReferenceSiteKind, resolveReference } from "./references"; +import type { NormalisationNote } from "../document/normalise"; +import { + type ReferenceScope, + type ReferenceSiteKind, + resolveReference, +} from "../document/references"; export type EvaluationResult = { ok: true; value: bigint } | { ok: false; reason: string }; diff --git a/packages/tx-manifest/src/inputRules.test.ts b/packages/tx-manifest/src/evaluation/inputRules.test.ts similarity index 96% rename from packages/tx-manifest/src/inputRules.test.ts rename to packages/tx-manifest/src/evaluation/inputRules.test.ts index 2d76164..67aca7d 100644 --- a/packages/tx-manifest/src/inputRules.test.ts +++ b/packages/tx-manifest/src/evaluation/inputRules.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; +import { type NormalisedAction, normaliseManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; import { encodeDataParts } from "./encode"; import { resolveInputRules } from "./inputRules"; -import { type NormalisedAction, normaliseManifest } from "./normalise"; -import type { ReferenceScope } from "./references"; // Three constructs the approved specification names as in scope because lending_v3 and // last_will cannot be built without them. The shapes below are the ones those manifests diff --git a/packages/tx-manifest/src/inputRules.ts b/packages/tx-manifest/src/evaluation/inputRules.ts similarity index 95% rename from packages/tx-manifest/src/inputRules.ts rename to packages/tx-manifest/src/evaluation/inputRules.ts index 0258ad9..8105a79 100644 --- a/packages/tx-manifest/src/inputRules.ts +++ b/packages/tx-manifest/src/evaluation/inputRules.ts @@ -1,7 +1,7 @@ +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; import { evaluateExpression } from "./evaluate"; -import { asArray, asRecord } from "./json"; -import type { NormalisationNote, NormalisedAction } from "./normalise"; -import { type ReferenceScope, resolveReference } from "./references"; /** What an action says about one of its inputs beyond where the money comes from. */ export type InputRule = { diff --git a/packages/tx-manifest/src/plan.test.ts b/packages/tx-manifest/src/evaluation/plan.test.ts similarity index 97% rename from packages/tx-manifest/src/plan.test.ts rename to packages/tx-manifest/src/evaluation/plan.test.ts index 2364229..719b64d 100644 --- a/packages/tx-manifest/src/plan.test.ts +++ b/packages/tx-manifest/src/evaluation/plan.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; -import { findAction, type NormalisedAction, normaliseManifest } from "./normalise"; +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import { findAction, type NormalisedAction, normaliseManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; import { planAction } from "./plan"; -import type { ReferenceScope } from "./references"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const MANIFEST = normaliseManifest(p2pkManifest as unknown as Record<string, unknown>).manifest; diff --git a/packages/tx-manifest/src/plan.ts b/packages/tx-manifest/src/evaluation/plan.ts similarity index 96% rename from packages/tx-manifest/src/plan.ts rename to packages/tx-manifest/src/evaluation/plan.ts index e6cbaf7..5c6eb1e 100644 --- a/packages/tx-manifest/src/plan.ts +++ b/packages/tx-manifest/src/evaluation/plan.ts @@ -1,8 +1,8 @@ +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; import { encodeDataParts } from "./encode"; import { evaluateExpression } from "./evaluate"; -import { asArray, asRecord } from "./json"; -import type { NormalisationNote, NormalisedAction } from "./normalise"; -import { type ReferenceScope, resolveReference } from "./references"; /** * A concrete amount the wallet worked out for one of the action's outputs. diff --git a/packages/tx-manifest/src/validate.ts b/packages/tx-manifest/src/evaluation/validate.ts similarity index 92% rename from packages/tx-manifest/src/validate.ts rename to packages/tx-manifest/src/evaluation/validate.ts index 1cf31b6..ddcb066 100644 --- a/packages/tx-manifest/src/validate.ts +++ b/packages/tx-manifest/src/evaluation/validate.ts @@ -1,7 +1,7 @@ +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; import { evaluateCondition } from "./evaluate"; -import { asArray, asRecord } from "./json"; -import type { NormalisationNote, NormalisedAction } from "./normalise"; -import type { ReferenceScope } from "./references"; export type ValidationFailure = { reason: string }; diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts index 44fbc7f..4df8a33 100644 --- a/packages/tx-manifest/src/index.ts +++ b/packages/tx-manifest/src/index.ts @@ -6,34 +6,35 @@ * nothing between calls: a wallet supplies the chain reads and the signing, and the same * request twice produces the same plan. That is what makes it a package rather than part * of one wallet — and it is enforced rather than intended, by `stateless.test.ts`. + * + * This surface is what a wallet needs and nothing else. Everything a wallet does with + * this package falls into four steps, and they are listed here in that order rather than + * alphabetically, because the order is the point: read the request, read the chain, + * review the action, show a person what it does. A module absent from here is private + * even though its directory is not hidden — the way to make one public is to add it, + * deliberately, when something outside actually needs it. */ +// 1. What the site sent, checked into a shape the rest can rely on. +export type { ParsedLiquidProcessCtParams } from "./request/request"; +export { parseLiquidProcessCtParams } from "./request/validation"; + +// 2. What the chain says, which only a wallet can ask for. export { type ReadFeeRate, type ReadTxOut, - type TxOutAtOutPoint, createEsploraFeeRateReader, createEsploraTxOutReader, -} from "./chainRead"; -export { type ParsedTxOut, txOutAt } from "./txOut"; -export { - type ConfirmationModel, - type ShownConfirmation, - confirmationModel, - describeOrigin, - toShownConfirmation, -} from "./confirmation"; -export { estimateFeeSats, estimateVsize } from "./fee"; -export { guardSpentInputs } from "./inputGuard"; -export { findAction, normaliseManifest } from "./normalise"; -export { type Origin, type Provenanced, computed, fromSite, verified } from "./provenance"; -export { refuseUnsupported } from "./refuse"; -export { ignored, inspectConstructs, loadBearing } from "./registry"; +} from "./chain/chainRead"; +export { txOutAt } from "./chain/txOut"; +export { estimateFeeSats } from "./fee"; + +// 3. The action, resolved into a reviewed plan or a refusal — and afterwards, the check +// that what came back spends only what was asked for. export { type ManifestReview, isRefusal, reviewManifestAction } from "./review"; -export { spentInputs } from "./spentInputs"; -export type { ParsedLiquidProcessCtParams, RequestPart } from "./types"; -export { - type MalformedRequest, - type ParseRequestResult, - parseLiquidProcessCtParams, -} from "./validation"; +export { guardSpentInputs } from "./chain/inputGuard"; +export { spentInputs } from "./chain/spentInputs"; + +// 4. What a person is shown, and where each value on that screen came from. +export { type ShownConfirmation, describeOrigin, toShownConfirmation } from "./confirmation"; +export { type Provenanced, computed, fromSite, verified } from "./confirmation/provenance"; diff --git a/packages/tx-manifest/src/types.ts b/packages/tx-manifest/src/request/request.ts similarity index 100% rename from packages/tx-manifest/src/types.ts rename to packages/tx-manifest/src/request/request.ts diff --git a/packages/tx-manifest/src/requirements.test.ts b/packages/tx-manifest/src/request/requirements.test.ts similarity index 96% rename from packages/tx-manifest/src/requirements.test.ts rename to packages/tx-manifest/src/request/requirements.test.ts index 37c996a..22d2335 100644 --- a/packages/tx-manifest/src/requirements.test.ts +++ b/packages/tx-manifest/src/request/requirements.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; -import groupedManifest from "./__fixtures__/p2pk-grouped.manifest.json"; -import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; -import { normaliseManifest } from "./normalise"; +import groupedManifest from "../__fixtures__/p2pk-grouped.manifest.json"; +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import { normaliseManifest } from "../document/normalise"; +import type { ParsedLiquidProcessCtParams } from "./request"; import { resolveActionRequirements } from "./requirements"; -import type { ParsedLiquidProcessCtParams } from "./types"; // The fixture is the published p2pk manifest at txmanifest-wallet // 7d56516a1a1e44a586f25d45a34619c3953758dd, unmodified. Expectations below come from diff --git a/packages/tx-manifest/src/requirements.ts b/packages/tx-manifest/src/request/requirements.ts similarity index 94% rename from packages/tx-manifest/src/requirements.ts rename to packages/tx-manifest/src/request/requirements.ts index 5f8f398..a58dc9d 100644 --- a/packages/tx-manifest/src/requirements.ts +++ b/packages/tx-manifest/src/request/requirements.ts @@ -1,8 +1,8 @@ -import { asRecord } from "./json"; -import { findAction, type NormalisedManifest } from "./normalise"; -import { instanceReferences } from "./references"; -import { covenantSites, namedUtxoTypes } from "./sites"; -import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } from "./types"; +import { asRecord } from "../document/json"; +import { findAction, type NormalisedManifest } from "../document/normalise"; +import { instanceReferences } from "../document/references"; +import { covenantSites, namedUtxoTypes } from "../document/sites"; +import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } from "./request"; /** * Works out what the chosen action actually needs from the request, and what of that is diff --git a/packages/tx-manifest/src/validation.test.ts b/packages/tx-manifest/src/request/validation.test.ts similarity index 96% rename from packages/tx-manifest/src/validation.test.ts rename to packages/tx-manifest/src/request/validation.test.ts index fbcaf07..ea4ae93 100644 --- a/packages/tx-manifest/src/validation.test.ts +++ b/packages/tx-manifest/src/request/validation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; import { parseLiquidProcessCtParams } from "./validation"; // AC-13's other half: a site cannot set the fee. The request contract has no place to put diff --git a/packages/tx-manifest/src/validation.ts b/packages/tx-manifest/src/request/validation.ts similarity index 96% rename from packages/tx-manifest/src/validation.ts rename to packages/tx-manifest/src/request/validation.ts index 0e63347..cbfcae3 100644 --- a/packages/tx-manifest/src/validation.ts +++ b/packages/tx-manifest/src/request/validation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { ParsedLiquidProcessCtParams } from "./types"; +import type { ParsedLiquidProcessCtParams } from "./request"; const jsonObjectSchema = z.record(z.string(), z.unknown()); diff --git a/packages/tx-manifest/src/coinSelection.test.ts b/packages/tx-manifest/src/review/coinSelection.test.ts similarity index 100% rename from packages/tx-manifest/src/coinSelection.test.ts rename to packages/tx-manifest/src/review/coinSelection.test.ts diff --git a/packages/tx-manifest/src/coinSelection.ts b/packages/tx-manifest/src/review/coinSelection.ts similarity index 100% rename from packages/tx-manifest/src/coinSelection.ts rename to packages/tx-manifest/src/review/coinSelection.ts diff --git a/packages/tx-manifest/src/review.test.ts b/packages/tx-manifest/src/review/index.test.ts similarity index 97% rename from packages/tx-manifest/src/review.test.ts rename to packages/tx-manifest/src/review/index.test.ts index 04ce913..47b1fc4 100644 --- a/packages/tx-manifest/src/review.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; -import groupedManifest from "./__fixtures__/p2pk-grouped.manifest.json"; -import p2pkManifest from "./__fixtures__/p2pk.manifest.json"; -import type { TxOutAtOutPoint } from "./chainRead"; -import { estimateFeeSats } from "./fee"; -import { isRefusal, reviewManifestAction } from "./review"; -import { txOutAt } from "./txOut"; -import type { ParsedLiquidProcessCtParams } from "./types"; +import groupedManifest from "../__fixtures__/p2pk-grouped.manifest.json"; +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import type { TxOutAtOutPoint } from "../chain/chainRead"; +import { txOutAt } from "../chain/txOut"; +import { estimateFeeSats } from "../fee"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import { isRefusal, reviewManifestAction } from "./index"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const SOURCE_PATH = "./p2pk.simf"; diff --git a/packages/tx-manifest/src/review.ts b/packages/tx-manifest/src/review/index.ts similarity index 93% rename from packages/tx-manifest/src/review.ts rename to packages/tx-manifest/src/review/index.ts index 24f2c12..687b237 100644 --- a/packages/tx-manifest/src/review.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -1,26 +1,30 @@ -import type { ReadFeeRate, ReadTxOut } from "./chainRead"; -import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; -import { resolveComputedParams } from "./computed"; -import { type ConfirmationModel, confirmationModel } from "./confirmation"; -import { type CompileCovenant, covenantMatchesChain, deriveCovenantAddress } from "./covenant"; -import { type CompileScriptPubKey, covenantHashFrom } from "./covenantHash"; -import { estimateFeeSats } from "./fee"; -import { type InputRule, resolveInputRules } from "./inputRules"; -import { asArray, asRecord } from "./json"; +import type { ReadFeeRate, ReadTxOut } from "../chain/chainRead"; +import { type ConfirmationModel, confirmationModel } from "../confirmation"; +import { resolveComputedParams } from "../covenants/computed"; +import { + type CompileCovenant, + covenantMatchesChain, + deriveCovenantAddress, +} from "../covenants/covenant"; +import { type CompileScriptPubKey, covenantHashFrom } from "../covenants/covenantHash"; +import { asArray, asRecord } from "../document/json"; import { findAction, type NormalisationNote, normaliseInstance, normaliseManifest, -} from "./normalise"; -import { planAction } from "./plan"; -import type { ReferenceScope } from "./references"; -import { buildMode, refuseUnsupported } from "./refuse"; -import { type ConstructFinding, ignored, inspectConstructs } from "./registry"; -import { resolveActionRequirements } from "./requirements"; -import { covenantSites } from "./sites"; -import type { ParsedLiquidProcessCtParams } from "./types"; -import { checkValidations } from "./validate"; +} from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import { buildMode, refuseUnsupported } from "../document/refuse"; +import { type ConstructFinding, ignored, inspectConstructs } from "../document/registry"; +import { covenantSites } from "../document/sites"; +import { type InputRule, resolveInputRules } from "../evaluation/inputRules"; +import { planAction } from "../evaluation/plan"; +import { checkValidations } from "../evaluation/validate"; +import { estimateFeeSats } from "../fee"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import { resolveActionRequirements } from "../request/requirements"; +import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; /** * What the wallet established for itself about one covenant this action touches. diff --git a/packages/tx-manifest/src/stateless.test.ts b/packages/tx-manifest/src/stateless.test.ts index 9ec708e..a5d4790 100644 --- a/packages/tx-manifest/src/stateless.test.ts +++ b/packages/tx-manifest/src/stateless.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; // AC-14 and D7: a person who wipes the wallet, the browser or the machine must be able to @@ -11,13 +11,52 @@ import { fileURLToPath } from "node:url"; const RUNTIME = dirname(fileURLToPath(import.meta.url)); -function sources(): { name: string; text: string }[] { - return readdirSync(RUNTIME) - .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) - .map((name) => ({ name, text: readFileSync(join(RUNTIME, name), "utf8") })); +/** + * Every source of the runtime, at whatever depth it sits. + * + * Recursive deliberately. This walked one directory while the package was one directory, + * and the day the modules moved into their owners it would have gone on passing while + * inspecting two files out of thirty — every check below reporting clean because it had + * stopped looking rather than because there was nothing to find. `name` carries the path + * from the package root so an offender can be located, and `dir` is what an import + * specifier resolves against. + */ +function sources(): { dir: string; name: string; text: string }[] { + const found: { dir: string; name: string; text: string }[] = []; + + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + + if (entry.isDirectory()) { + // Fixtures are other people's documents, not this runtime's source. + if (entry.name !== "__fixtures__") { + walk(path); + } + + continue; + } + + if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) { + found.push({ dir, name: relative(RUNTIME, path), text: readFileSync(path, "utf8") }); + } + } + }; + + walk(RUNTIME); + + return found; } describe("the runtime reads nothing it remembered", () => { + // The checks below all report clean when they find nothing, which is the same answer they + // give when they are looking at nothing. That is not hypothetical: this file walked one + // directory, and when the modules moved into their owners it went on passing over two + // files out of thirty. So the first assertion is that there is something to assert about. + test("and the checks below are looking at the whole runtime", () => { + expect(sources().length).toBeGreaterThan(25); + }); + // Everything the runtime needs arrives in the request or derives from the recovery // phrase. Anything that reads what a previous run wrote down would make a second run on // a restored wallet behave differently from the first, which is the whole failure. @@ -41,16 +80,30 @@ describe("the runtime reads nothing it remembered", () => { } test("and none imports anything outside this runtime and its own dependencies", () => { - const allowed = /^(?:\.\/[a-zA-Z]+|@noble\/hashes\/[a-z0-9.]+|zod)$/; + const dependencies = /^(?:@noble\/hashes\/[a-z0-9.]+|zod)$/; const offenders: string[] = []; - for (const { name, text } of sources()) { + for (const { dir, name, text } of sources()) { for (const match of text.matchAll(/from "(?<specifier>[^"]+)"/g)) { const specifier = match.groups?.specifier ?? ""; - if (!allowed.test(specifier)) { - offenders.push(`${name} → ${specifier}`); + if (dependencies.test(specifier)) { + continue; } + + // A relative import is judged by where it lands rather than by how it is + // written. Once the package has directories, `../` is the ordinary way one + // module reaches its sibling — and it is also how a module would reach out of + // the package altogether. Only resolving it tells the two apart. + if (specifier.startsWith(".")) { + const escapes = relative(RUNTIME, resolve(dir, specifier)).startsWith(".."); + + if (!escapes) { + continue; + } + } + + offenders.push(`${name} → ${specifier}`); } } From 40657e94b1217c72cc17363d7527e9c5b11a0b6d Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 13:01:52 +0300 Subject: [PATCH 079/124] fix(settings): tell a person what they can do, not what broke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract identity row put the thrown error's own message on the screen. That message is written for whoever is debugging the wallet — it can name a module, a network kind or a derivation path, none of which the reader can act on, and it changes whenever the code under it changes. There is exactly one thing a person can do about any failure of this read, so that is what it now says. The technical detail stays where it was already going, which is the diagnostic path rather than the screen. One of seven places in the extension that render error.message directly. The other six predate this change. --- .../pages/Account/components/ContractIdentityRow.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx index 1553da3..a20e0b7 100644 --- a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx +++ b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx @@ -52,8 +52,13 @@ export function ContractIdentityRow({ accountGroupId }: { accountGroupId: Accoun try { setIdentity(await readLiquidContractIdentity(accountGroupId)); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Could not read the contract identity."); + } catch { + // What a person is told is chosen here, not carried up from wherever it broke. The + // thrown error's own message is written for whoever is debugging the wallet: it may + // name a module, a network kind or a derivation path, none of which this reader can + // act on, and it changes whenever the code below changes. There is exactly one thing + // they can do about any failure of this read, so that is what it says. + setError("Could not read the contract identity. Try again."); } finally { setLoading(false); } From 3f7756a135d0345e77c13e1f3a0e9d4def47881c Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 13:03:45 +0300 Subject: [PATCH 080/124] chore(lint): run the fixer the repository already provides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 41 warnings sit in files this change touched, 34 of them in files it created, while every verification receipt it wrote said it introduced none. The linter's own --fix accounts for 19 of them and leaves the suite at 508 green: they existed because nobody had run the command that removes them. The 22 that remain are not mechanical and are itemised in the sweep, to be settled one at a time. Two of them are a design question rather than a lint finding — the review path awaits a chain read per covenant inside a loop, and whether those may run concurrently is about the reader and the fee model. --- .../liquid/adapters/smplx/loadSmplxWasm.test.ts | 12 ++++++------ .../processConfidentialTransaction/index.test.ts | 8 ++++---- packages/tx-manifest/src/chain/bytes.ts | 2 +- packages/tx-manifest/src/chain/chainRead.ts | 2 +- packages/tx-manifest/src/chain/inputGuard.test.ts | 9 +++++++-- packages/tx-manifest/src/chain/txOut.ts | 2 +- packages/tx-manifest/src/corpus.test.ts | 2 +- packages/tx-manifest/src/evaluation/encode.ts | 2 +- packages/tx-manifest/src/review/coinSelection.ts | 4 +++- packages/tx-manifest/src/review/index.test.ts | 2 +- 10 files changed, 26 insertions(+), 19 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index bb47674..f2c6e31 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -253,7 +253,7 @@ describe("finalising a transaction", () => { /** An explicit Elements output of `sats` of the policy asset, paying to `scriptHex`. */ function encodeTxOut(sats: bigint, scriptHex: string): string { - const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); const value = sats.toString(16).padStart(16, "0"); const scriptLen = (scriptHex.length / 2).toString(16).padStart(2, "0"); @@ -324,7 +324,7 @@ describe("covenant inputs and the dry-run", () => { function covenantTxOut(sats: bigint): string { const contract = new bindings.Contract(P2PK_SOURCE, ARGS); const script = contract.scriptPubKeyHex("liquid-testnet"); - const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); const value = sats.toString(16).padStart(16, "0"); const scriptLen = (script.length / 2).toString(16).padStart(2, "0"); @@ -377,7 +377,7 @@ describe("covenant inputs and the dry-run", () => { test("refuses to dry-run an input that is not a covenant", () => { const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); const builder = new bindings.TransactionBuilder(); - const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); const walletTxOut = `01${assetLe}0100000000000186a000${"16"}${signer.scriptPubKeyHex()}`; builder.addWalletInput(TXID, 0, walletTxOut); @@ -408,7 +408,7 @@ describe("signing a covenant that authenticates its spender", () => { const RATE = 1000; function txOut(sats: bigint, scriptHex: string): string { - const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); const value = sats.toString(16).padStart(16, "0"); const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); @@ -549,7 +549,7 @@ describe("what a signed transaction says it spends", () => { const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; function txOut(sats: bigint, scriptHex: string): string { - const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); const value = sats.toString(16).padStart(16, "0"); const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); @@ -800,7 +800,7 @@ describe("a transaction balances against the fee that is charged", () => { const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; function txOut(sats: bigint, scriptHex: string): string { - const assetLe = (POLICY_ASSET.match(/../g) ?? []).reverse().join(""); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); const value = sats.toString(16).padStart(16, "0"); const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index c14046d..ce33443 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -50,8 +50,8 @@ const FUNDING_TXID = "d".repeat(64); function serialise(spends: { txid: string; vout: number }[]): string { const inputs = spends .map(({ txid, vout }) => { - const reversed = (txid.match(/../g) ?? []).reverse().join(""); - const index = vout.toString(16).padStart(8, "0").match(/../g)!.reverse().join(""); + const reversed = (txid.match(/../g) ?? []).toReversed().join(""); + const index = vout.toString(16).padStart(8, "0").match(/../g)!.toReversed().join(""); return `${reversed}${index}00ffffffff`; }) @@ -180,7 +180,7 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { // Answers with bytes and reads them back through the same parser the real reader uses, // so this cannot hand over an output the chain could not have produced. readTxOut: () => async () => { - const asset = `01${(POLICY_ASSET.match(/../g) ?? []).reverse().join("")}`; + const asset = `01${(POLICY_ASSET.match(/../g) ?? []).toReversed().join("")}`; const value = `01${(42_000).toString(16).padStart(16, "0")}`; const script = `${(DERIVED_SCRIPT.length / 2).toString(16).padStart(2, "0")}${DERIVED_SCRIPT}`; const parsed = txOutAt(`02000000000001${asset}${value}00${script}00000000`, 0); @@ -370,7 +370,7 @@ describe("processLiquidConfidentialTransaction on a restored wallet", () => { await subject().method(params(), watched); - expect([...new Set(read)].sort()).toEqual([ + expect([...new Set(read)].toSorted()).toEqual([ "authorization", "chain", "keyManagerState", diff --git a/packages/tx-manifest/src/chain/bytes.ts b/packages/tx-manifest/src/chain/bytes.ts index 78277a9..86e5ac5 100644 --- a/packages/tx-manifest/src/chain/bytes.ts +++ b/packages/tx-manifest/src/chain/bytes.ts @@ -55,7 +55,7 @@ export function readReversedHex(reader: Reader, length: number): string | undefi reader.at += length; - return encodeHex(slice.reverse()); + return encodeHex(slice.toReversed()); } export function readUint32(reader: Reader): number | undefined { diff --git a/packages/tx-manifest/src/chain/chainRead.ts b/packages/tx-manifest/src/chain/chainRead.ts index 4d8f26e..de0f004 100644 --- a/packages/tx-manifest/src/chain/chainRead.ts +++ b/packages/tx-manifest/src/chain/chainRead.ts @@ -116,7 +116,7 @@ export function createEsploraFeeRateReader( const targets = Object.keys(body) .map(Number) .filter((value) => Number.isFinite(value)) - .sort((a, b) => a - b); + .toSorted((a, b) => a - b); const chosen = targets.find((value) => value >= targetBlocks) ?? targets.at(-1); const satsPerVbyte = chosen === undefined ? undefined : body[String(chosen)]; diff --git a/packages/tx-manifest/src/chain/inputGuard.test.ts b/packages/tx-manifest/src/chain/inputGuard.test.ts index 05230fa..2232c9d 100644 --- a/packages/tx-manifest/src/chain/inputGuard.test.ts +++ b/packages/tx-manifest/src/chain/inputGuard.test.ts @@ -17,9 +17,14 @@ function transaction(...spends: { issuance?: boolean; txid: string; vout: number const count = spends.length.toString(16).padStart(2, "0"); const inputs = spends .map(({ issuance, txid, vout }) => { - const reversed = (txid.match(/../g) ?? []).reverse().join(""); + const reversed = (txid.match(/../g) ?? []).toReversed().join(""); const marked = issuance ? vout | 0x80_00_00_00 : vout; - const index = (marked >>> 0).toString(16).padStart(8, "0").match(/../g)!.reverse().join(""); + const index = (marked >>> 0) + .toString(16) + .padStart(8, "0") + .match(/../g)! + .toReversed() + .join(""); return `${reversed}${index}00ffffffff`; }) diff --git a/packages/tx-manifest/src/chain/txOut.ts b/packages/tx-manifest/src/chain/txOut.ts index 96857e4..9cf7e92 100644 --- a/packages/tx-manifest/src/chain/txOut.ts +++ b/packages/tx-manifest/src/chain/txOut.ts @@ -210,7 +210,7 @@ function readRawUint32(reader: Reader): number | undefined { } function reverseHex(hex: string): string { - return (hex.match(/../g) ?? []).reverse().join(""); + return (hex.match(/../g) ?? []).toReversed().join(""); } function bigEndian(hex: string): bigint { diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index 2a2dd83..8530ccd 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -190,7 +190,7 @@ describe("what the corpus uses and this wallet does not read", () => { } } - expect([...unread].sort()).toEqual([ + expect([...unread].toSorted()).toEqual([ "confidential", "create_instance", "default", diff --git a/packages/tx-manifest/src/evaluation/encode.ts b/packages/tx-manifest/src/evaluation/encode.ts index c9fc048..8816e5a 100644 --- a/packages/tx-manifest/src/evaluation/encode.ts +++ b/packages/tx-manifest/src/evaluation/encode.ts @@ -265,7 +265,7 @@ function pad(hex: string, target: unknown, align: unknown): EncodedBytes { } function reverse(hex: string): string { - return (hex.match(/../g) ?? []).reverse().join(""); + return (hex.match(/../g) ?? []).toReversed().join(""); } function asCount(value: unknown): bigint | undefined { diff --git a/packages/tx-manifest/src/review/coinSelection.ts b/packages/tx-manifest/src/review/coinSelection.ts index cccc98a..a2fca3b 100644 --- a/packages/tx-manifest/src/review/coinSelection.ts +++ b/packages/tx-manifest/src/review/coinSelection.ts @@ -51,7 +51,9 @@ export function selectCoins( const needed = targetSats + headroomSats; const usable = available.filter((utxo) => utxo.spendable && !utxo.confidential); - const spendable = usable.slice().sort((a, b) => (toSats(b.amount) > toSats(a.amount) ? 1 : -1)); + const spendable = usable + .slice() + .toSorted((a, b) => (toSats(b.amount) > toSats(a.amount) ? 1 : -1)); // What is there and cannot be used, so a refusal can say so. A person looking at a // balance that covers the amount needs to be told why it does not count, rather than diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts index 47b1fc4..d6cbfca 100644 --- a/packages/tx-manifest/src/review/index.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -68,7 +68,7 @@ const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585 const readTxOut = (scriptPubKeyHex: string, amountSats = "42000") => async (): Promise<TxOutAtOutPoint> => { - const asset = `01${(POLICY_ASSET.match(/../g) ?? []).reverse().join("")}`; + const asset = `01${(POLICY_ASSET.match(/../g) ?? []).toReversed().join("")}`; const value = `01${BigInt(amountSats).toString(16).padStart(16, "0")}`; const script = `${(scriptPubKeyHex.length / 2).toString(16).padStart(2, "0")}${scriptPubKeyHex}`; const transaction = `02000000000001${asset}${value}00${script}00000000`; From e519be5fefdeb6b2adf564358dc64276a66b301c Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 13:11:02 +0300 Subject: [PATCH 081/124] chore(liquid): give the scan worker the logger it already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten console.warn calls, each carrying a hand-written [liquid-sync] prefix, which is what a child logger's scope is for. Two of them this change added, eight inherited — and converting two would have left two conventions in one file. The facade is a console wrapper with no transports, so it behaves the same inside a worker as on a page. Verified rather than assumed before moving anything: it reaches no storage and no extension API. --- .../lwk/sync-worker/liquidScanCore.ts | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts index f1ecc68..de68e57 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts @@ -1,3 +1,5 @@ +import { logger } from "@/core/logger"; + import type { LiquidActivityPage, LiquidAssetBalance, @@ -10,6 +12,15 @@ import { createLwkNetwork, type LwkNetwork } from "../createLwkNetwork"; import { loadLwkWasm, type LwkWasmModule } from "../loadLwkWasm"; import { readWalletActivityForAsset, readWalletAssetBalances } from "../wallet/readWalletData"; import { readWalletUtxos } from "../wallet/readWalletUtxos"; + +/** + * The scan worker's own logger. + * + * These lines carried a hand-written "[liquid-sync]" prefix on every call, which is what a + * child logger's scope is for. The facade is a console wrapper with no transports, so it + * behaves the same inside a worker as it does on a page. + */ +const log = logger.child({ module: "liquid-sync" }); import { type AssetMetadata, resolveIssuedAssetMetadata } from "../wallet/resolveAssetMetadata"; type LwkWollet = InstanceType<LwkWasmModule["Wollet"]>; @@ -63,11 +74,11 @@ export async function scanFresh(input: LiquidScanInput): Promise<Uint8Array | nu const wollet = new lwk.Wollet(network, new lwk.WolletDescriptor(input.descriptor)); const client = createLwkBlockchainClient(lwk, input.chain, network); - console.warn("[liquid-sync] scan (fresh) fullScan…", { chainId: input.chain.id, id: input.id }); + log.warn("scan (fresh) fullScan…", { chainId: input.chain.id, id: input.id }); const scanStartedAt = Date.now(); const update = await client.fullScan(wollet); - console.warn("[liquid-sync] scan (fresh) done", { + log.warn("scan (fresh) done", { hasUpdate: Boolean(update), id: input.id, ms: Date.now() - scanStartedAt, @@ -96,12 +107,12 @@ export async function broadcastPset(input: LiquidBroadcastInput): Promise<string const client = createLwkBlockchainClient(lwk, input.chain, network); const pset = new lwk.Pset(input.psetBase64); - console.warn("[liquid-sync] broadcast…", { chainId: input.chain.id, id: input.id }); + log.warn("broadcast…", { chainId: input.chain.id, id: input.id }); const startedAt = Date.now(); const txid = await client.broadcast(pset); const txidString = txid.toString(); - console.warn("[liquid-sync] broadcast done", { + log.warn("broadcast done", { id: input.id, ms: Date.now() - startedAt, txid: txidString, @@ -130,12 +141,12 @@ export async function broadcastTransaction(input: LiquidBroadcastTxInput): Promi const client = createLwkBlockchainClient(lwk, input.chain, network); const transaction = lwk.Transaction.fromString(input.txHex); - console.warn("[liquid-sync] broadcast tx…", { chainId: input.chain.id, id: input.id }); + log.warn("broadcast tx…", { chainId: input.chain.id, id: input.id }); const startedAt = Date.now(); const txid = await client.broadcastTx(transaction); const txidString = txid.toString(); - console.warn("[liquid-sync] broadcast tx done", { + log.warn("broadcast tx done", { id: input.id, ms: Date.now() - startedAt, txid: txidString, @@ -156,7 +167,7 @@ export async function scanAndRead(input: LiquidScanInput): Promise<LiquidWalletS let wollet = wolletCache.get(cacheKey); - console.warn("[liquid-sync] scanAndRead start", { + log.warn("scanAndRead start", { cachedWollet: Boolean(wollet), chainId: input.chain.id, id: input.id, @@ -170,11 +181,11 @@ export async function scanAndRead(input: LiquidScanInput): Promise<LiquidWalletS // A fresh client each time picks up backend-setting changes; the wollet is reused. const client = createLwkBlockchainClient(lwk, input.chain, network); - console.warn("[liquid-sync] fullScan…", { id: input.id }); + log.warn("fullScan…", { id: input.id }); const scanStartedAt = Date.now(); const update = await client.fullScan(wollet); - console.warn("[liquid-sync] fullScan done", { + log.warn("fullScan done", { hasUpdate: Boolean(update), id: input.id, ms: Date.now() - scanStartedAt, @@ -201,7 +212,7 @@ export async function scanAndRead(input: LiquidScanInput): Promise<LiquidWalletS // so a later step can serve getUTXOs/getBalance from the persisted snapshot instead of rescanning. const utxos = readWalletUtxos(wollet); - console.warn("[liquid-sync] scanAndRead done", { + log.warn("scanAndRead done", { assetCount: assets.length, id: input.id, ms: Date.now() - scanStartedAt, From eee88be34191011baa981bf21fbfcc6df9ed94c5 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 13:27:55 +0300 Subject: [PATCH 082/124] chore(lint): settle every warning this change owns, by fixing or by saying why not Zero left in the files this work created or touched, from 41. Two in the review path are disabled with the reason rather than obeyed: the loop returns on the first covenant it refuses, so running the sites concurrently would compile contracts and send chain reads for covenants after the answer is known, and would make which refusal a person is shown depend on which request finished first instead of on the order the manifest declares. The rest are test-file rules that are wrong about test files. Transaction-byte fixtures are joined where one field ends and the next begins; a helper that builds the case it sits in belongs beside its assertion; wasm-bindgen's exported names cannot be renamed. Each disabled narrowly with its reason. One was a genuine improvement and is simply fixed. Six warnings in three files this change did not create are left alone and recorded, per the sweep's own boundary. --- .../adapters/lwk/wallet/sendTransfer/index.test.ts | 1 + .../chains/liquid/adapters/smplx/loadSmplxWasm.test.ts | 2 ++ .../chains/liquid/application/contractIdentity.test.ts | 1 + packages/tx-manifest/src/chain/chainRead.test.ts | 1 + packages/tx-manifest/src/chain/inputGuard.test.ts | 1 + packages/tx-manifest/src/confirmation/provenance.test.ts | 1 + packages/tx-manifest/src/evaluation/encode.test.ts | 1 + packages/tx-manifest/src/evaluation/inputRules.test.ts | 1 + packages/tx-manifest/src/review/coinSelection.test.ts | 2 +- packages/tx-manifest/src/review/index.ts | 9 +++++++++ 10 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts index ca45eb0..aabab28 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable no-extraneous-class -- this stands in for a chain-library class the real code constructs with new; a function would not be substitutable for it import { describe, expect, mock, test } from "bun:test"; /** diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index f2c6e31..da82a04 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable consistent-function-scoping -- each helper builds the case it sits in, and reading it beside the assertion is the point +// oxlint-disable no-underscore-dangle -- these are wasm-bindgen's own exported names; renaming them would stop the module loading import { beforeAll, describe, expect, test } from "bun:test"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; diff --git a/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts index 03ad69d..e971d30 100644 --- a/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable no-await-in-loop -- the cases run one at a time because each asserts about the signer being freed before the next takes one import { describe, expect, test } from "bun:test"; import type { LiquidChainRecord } from "../chains/LiquidChainRecord"; diff --git a/packages/tx-manifest/src/chain/chainRead.test.ts b/packages/tx-manifest/src/chain/chainRead.test.ts index 66a036c..fab47fd 100644 --- a/packages/tx-manifest/src/chain/chainRead.test.ts +++ b/packages/tx-manifest/src/chain/chainRead.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable no-await-in-loop -- these reads go to a real endpoint one transaction at a time on purpose; running them at once would make a rate limit look like a failing test import { describe, expect, test } from "bun:test"; import transactions from "../__fixtures__/testnet-transactions.json"; diff --git a/packages/tx-manifest/src/chain/inputGuard.test.ts b/packages/tx-manifest/src/chain/inputGuard.test.ts index 2232c9d..2555703 100644 --- a/packages/tx-manifest/src/chain/inputGuard.test.ts +++ b/packages/tx-manifest/src/chain/inputGuard.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable no-useless-concat -- these fixtures are transaction bytes, and the joins are where one field ends and the next begins; one literal would hide the only thing that makes them readable import { describe, expect, test } from "bun:test"; import { guardSpentInputs } from "./inputGuard"; diff --git a/packages/tx-manifest/src/confirmation/provenance.test.ts b/packages/tx-manifest/src/confirmation/provenance.test.ts index 7f2fad0..765ee10 100644 --- a/packages/tx-manifest/src/confirmation/provenance.test.ts +++ b/packages/tx-manifest/src/confirmation/provenance.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable consistent-function-scoping -- each test defines the renderer it is asserting about; hoisting them would make two different subjects one import { describe, expect, test } from "bun:test"; import { diff --git a/packages/tx-manifest/src/evaluation/encode.test.ts b/packages/tx-manifest/src/evaluation/encode.test.ts index 94134bf..33869a0 100644 --- a/packages/tx-manifest/src/evaluation/encode.test.ts +++ b/packages/tx-manifest/src/evaluation/encode.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable consistent-function-scoping -- the helper belongs to the case it builds, and reading it beside the assertion is the point import { describe, expect, test } from "bun:test"; import { encodeDataParts, encodeLeafItem } from "./encode"; diff --git a/packages/tx-manifest/src/evaluation/inputRules.test.ts b/packages/tx-manifest/src/evaluation/inputRules.test.ts index 67aca7d..4d41acd 100644 --- a/packages/tx-manifest/src/evaluation/inputRules.test.ts +++ b/packages/tx-manifest/src/evaluation/inputRules.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable consistent-function-scoping -- the helper belongs to the case it builds, and reading it beside the assertion is the point import { describe, expect, test } from "bun:test"; import { type NormalisedAction, normaliseManifest } from "../document/normalise"; diff --git a/packages/tx-manifest/src/review/coinSelection.test.ts b/packages/tx-manifest/src/review/coinSelection.test.ts index c57ef50..42554bf 100644 --- a/packages/tx-manifest/src/review/coinSelection.test.ts +++ b/packages/tx-manifest/src/review/coinSelection.test.ts @@ -87,7 +87,7 @@ describe("confidential outputs", () => { test("are never selected, however much they hold", () => { const result = selectCoins([blinded, explicit], 500n, 0n); - expect(result.ok && result.selected.map((utxo) => utxo.txid)).toEqual(["a"]); + expect(result.ok && result.selected.map((selected) => selected.txid)).toEqual(["a"]); }); test("and when they are why the account falls short, the refusal says so", () => { diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index 687b237..8f3d7be 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -223,6 +223,13 @@ export async function reviewManifestAction( }; for (const site of covenantSites(action)) { + // Sequential on purpose, and the rule is disabled here rather than obeyed. This loop + // returns on the first site it refuses, so running the sites concurrently would compile + // contracts and send chain reads for covenants after the answer is already known, and + // would make which refusal a person is shown depend on which request finished first + // instead of on the order the manifest declares. A covenant is refused in declared order + // or not at all. + // oxlint-disable-next-line no-await-in-loop const derived = await deriveCovenantAddress(manifest, { compile: input.compile, contractSources: request.contractSources, @@ -263,6 +270,8 @@ export async function reviewManifestAction( let onChain; try { + // Same loop, same reason: the first refusal wins, so nothing after it is worth reading. + // oxlint-disable-next-line no-await-in-loop onChain = await input.readTxOut(outpoint); } catch (error) { return { From 409b4a93fc6175a2c901bded01dadb4241f73663 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 13:39:22 +0300 Subject: [PATCH 083/124] feat(tx-manifest): give every refusal a name a program can branch on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit asked for BIP-22-like refusals and it was right. Every refusal the runtime produced was a sentence and nothing else, and the method collapsed all of them into one wire code — so a site telling 'this wallet will never build that' from 'your state file is out of date' had to parse English, and one of those is worth retrying while the other never is. Seventeen tokens, derived from the refusal branches that already exist rather than invented: the vocabulary is ours because the format defines error codes only for the validation rules a manifest itself declares, and none of these are that. They are the wallet's own statements about what it will not do. The prose is untouched at every site; the token travels beside it and reaches the caller in data.reject. Two standing checks. Every published manifest is built or refused by a name, never a sentence with nothing attached. And all five the corpus refuses today refuse on the same branch — asserted as the fact it is, so the day one refuses for a different reason that line is what says so. 517 tests, gate green, extension builds. --- .../index.test.ts | 19 ++++++ .../processConfidentialTransaction/index.ts | 6 +- packages/tx-manifest/src/corpus.test.ts | 41 ++++++++++++ packages/tx-manifest/src/document/refuse.ts | 67 ++++++++++++++++++- packages/tx-manifest/src/index.ts | 1 + packages/tx-manifest/src/review/index.ts | 45 +++++++++---- 6 files changed, 164 insertions(+), 15 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index ce33443..04ec805 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -256,6 +256,25 @@ describe("processLiquidConfidentialTransaction", () => { await expect(method(params({ action: "Withdraw" }), context())).rejects.toThrow(/Withdraw/); }); + + // Every refusal on this path shares one wire code, so the sentence is all a caller had to go + // on. A site telling "this wallet will never build that" from "your state file is out of + // date" had to parse English, and one of those is worth retrying while the other never is. + test("and names the refusal beside the sentence, so a caller can branch without reading it", async () => { + const { method } = subject(); + + const failure = await method(params({ action: "Withdraw" }), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string } }, + ); + + // `incomplete-request` rather than `no-such-action`, and the difference is worth pinning: + // working out what an action needs happens before looking the action up, so a request + // naming an action the manifest does not declare is refused as a request that cannot be + // built rather than as a missing name. The sentence still says "Withdraw" — the test above + // asserts that — and the token says which check answered. + expect(failure?.data?.reject).toBe("incomplete-request"); + }); }); // AC-10 end to end: the same protocol written in the grouped shape with the older diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index a3066b7..7859cae 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -299,9 +299,13 @@ export const createProcessLiquidConfidentialTransaction = ( }); if (isRefusal(result)) { + // The sentence is for a person; the token beside it is for the site. Every refusal + // on this path shares one wire code, so without the token a caller telling "this + // wallet will never build that" from "your state file is out of date" has to parse + // English — and one of those is worth retrying while the other never is. throw new WalletRpcInvalidParamsError( result.reason, - undefined, + { reject: result.reject }, WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, ); } diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index 8530ccd..f3abe19 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -44,6 +44,47 @@ function refusalFor(name: string): string { return refusal ? refusal.reason : ""; } +function rejectionOf(name: keyof typeof CORPUS) { + return refuseUnsupported(normalised(name).manifest, { + compilerVersion: "0.6.0", + contractSources: {}, + policyAsset: POLICY_ASSET, + })?.reject; +} + +// A refusal a site cannot tell from another refusal is a sentence, not an answer. Five of the +// seven published protocols are refused, and each has to arrive with a name a program can +// branch on beside the sentence a person reads — because "this wallet will never build that" +// and "your state file is out of date" are the same wire code and opposite advice. +describe("every refusal a published manifest earns is named, not only described", () => { + for (const name of Object.keys(CORPUS) as (keyof typeof CORPUS)[]) { + test(`${name} is either built or refused by a name`, () => { + const reason = refusalFor(name); + const reject = rejectionOf(name); + + // Exactly one of the two states, and never a sentence with no name attached. + expect(reason === "" ? reject === undefined : typeof reject === "string").toBe(true); + }); + } + + test("and every one the corpus refuses today refuses on a construct, which is a fact about the corpus", () => { + const named = (Object.keys(CORPUS) as (keyof typeof CORPUS)[]).map(rejectionOf).filter(Boolean); + + // Five of the seven, and all five on the same branch: each uses a construct this wallet + // does not implement, and that check runs before any other could fire. So the vocabulary + // is not exercised beyond one token by the published corpus — which is worth asserting + // rather than hiding, because the day a manifest is refused for a different reason this + // line is what says so. + expect(named).toEqual([ + "unimplemented-construct", + "unimplemented-construct", + "unimplemented-construct", + "unimplemented-construct", + "unimplemented-construct", + ]); + }); +}); + describe("every published manifest is read", () => { for (const name of Object.keys(CORPUS)) { test(`${name} normalises without throwing`, () => { diff --git a/packages/tx-manifest/src/document/refuse.ts b/packages/tx-manifest/src/document/refuse.ts index f9099fa..0b46732 100644 --- a/packages/tx-manifest/src/document/refuse.ts +++ b/packages/tx-manifest/src/document/refuse.ts @@ -2,7 +2,60 @@ import { asArray, asRecord } from "./json"; import type { NormalisedManifest } from "./normalise"; import { loadBearing, inspectConstructs } from "./registry"; -export type Refusal = { reason: string }; +/** + * What a refusal is called, so that a program can tell two of them apart. + * + * The sentence is for a person and is unchanged by this. The token is for the site that asked: + * without one, every refusal arrives as a single wire code and a paragraph of English, so "this + * wallet does not implement `sequence`" and "your state file names no output to spend" are + * indistinguishable to anything but a reader — and the first is permanent while the second is + * the site's to fix. + * + * Short, stable, lower-case and hyphenated, after BIP-22's reject reasons. The vocabulary is + * ours rather than the format's: txManifest defines error codes for the validation rules a + * manifest itself declares, and none of these are that. They are the wallet's own statements + * about what it will not do. + */ +export type RejectToken = + /** The protocol is for a chain this wallet does not build on. */ + | "foreign-chain" + /** A construct the format defines, in a load-bearing position, that this wallet does not implement. */ + | "unimplemented-construct" + /** A construct in a load-bearing position that no specification this wallet knows describes. */ + | "unrecognised-construct" + /** The manifest or a contract source asks for a compiler this wallet does not ship. */ + | "foreign-compiler" + /** A declared build mode that is neither on nor off. */ + | "unreadable-build-mode" + /** A witness this wallet cannot produce: not a signature, not its key, or not its sighash. */ + | "unproducible-witness" + /** An input or output in an asset this wallet does not move. */ + | "foreign-asset" + /** A covenant this wallet cannot build or spend. */ + | "unbuildable-utxo-type" + /** The request is missing something the chosen action actually references. */ + | "incomplete-request" + /** The manifest declares no action by that name. */ + | "no-such-action" + /** The state file names no output for a covenant the action spends. */ + | "no-utxo-to-spend" + /** The chain could not be read, so what sits at an outpoint is unknown. */ + | "chain-read-failed" + /** A covenant the wallet rebuilt does not match what the chain says holds the money. */ + | "covenant-mismatch" + /** The wallet could not establish a fee rate, and will not build without one. */ + | "no-fee-rate" + /** Nothing spendable sits at the one address this path can sign from. */ + | "no-funds-at-signing-address" + /** The wallet holds less than the action needs, in the form the action can spend. */ + | "shortfall" + /** An expression, encoding or protocol rule the manifest states could not be satisfied. */ + | "document-fault"; + +export type Refusal = { + reason: string; + reject: RejectToken; +}; /** * Every reason this runtime will not build an action, checked before anything is built. @@ -65,6 +118,7 @@ function refuseForeignChain(manifest: NormalisedManifest): Refusal | undefined { reason: `This protocol is for ${JSON.stringify(declared)}, and this wallet builds Liquid ` + "transactions.", + reject: "foreign-chain", }; } @@ -91,6 +145,7 @@ function refuseUnrecognisedConstruct(manifest: NormalisedManifest): Refusal | un "It will not sign a transaction built from a document it has only partly read." : `This protocol uses "${found.key}" at ${found.at}, which this wallet does not recognise. ` + "It will not sign a transaction built from a document it has only partly read.", + reject: found.declared ? "unimplemented-construct" : "unrecognised-construct", }; } @@ -118,6 +173,7 @@ function refuseForeignCompiler( `This protocol asks for SimplicityHL ${declared} and this wallet has ` + `${input.compilerVersion}. A different compiler derives a different address for the ` + "same contract, so there is nothing safe to build.", + reject: "foreign-compiler", }; } @@ -129,6 +185,7 @@ function refuseForeignCompiler( reason: `The contract at ${path} asks for SimplicityHL ${range} and this wallet has ` + `${input.compilerVersion}.`, + reject: "foreign-compiler", }; } } @@ -149,6 +206,7 @@ function refuseUnreadableBuildMode(manifest: NormalisedManifest): Refusal | unde `This protocol declares compile_debug_symbols as ${JSON.stringify(declared)}, which is ` + "neither on nor off. The wallet builds each contract the way its protocol states, and " + "cannot follow a statement it cannot read.", + reject: "unreadable-build-mode", }; } @@ -176,6 +234,7 @@ function refuseUnproducibleWitness(manifest: NormalisedManifest): Refusal | unde reason: `The witness ${name} at ${at} is a ${String(witness?.type)}, and this wallet ` + "can only produce a signature.", + reject: "unproducible-witness", }; } @@ -186,6 +245,7 @@ function refuseUnproducibleWitness(manifest: NormalisedManifest): Refusal | unde reason: `The witness ${name} at ${at} is sourced from ${String(source)}, and this ` + "wallet can only sign with a key it holds.", + reject: "unproducible-witness", }; } @@ -196,6 +256,7 @@ function refuseUnproducibleWitness(manifest: NormalisedManifest): Refusal | unde reason: `The witness ${name} at ${at} asks for ${String(sigType)}, and this wallet ` + "signs over the whole transaction.", + reject: "unproducible-witness", }; } } @@ -231,6 +292,7 @@ function refuseForeignAsset( reason: `${action.name} moves ${asset} at ${id}, and this wallet moves only the ` + "network's own asset.", + reject: "foreign-asset", }; } } @@ -263,6 +325,7 @@ function refuseUnbuildableUtxoType( reason: `The ${name} contract is a ${String(scriptType)} script, and this wallet builds ` + "Simplicity covenants.", + reject: "unbuildable-utxo-type", }; } @@ -271,6 +334,7 @@ function refuseUnbuildableUtxoType( reason: `The ${name} covenant is declared confidential. A Simplicity program cannot read a ` + "confidential commitment, so it could never check its own value.", + reject: "unbuildable-utxo-type", }; } @@ -281,6 +345,7 @@ function refuseUnbuildableUtxoType( reason: `The ${name} covenant holds ${asset}, and this wallet moves only the network's own ` + "asset.", + reject: "unbuildable-utxo-type", }; } } diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts index 4df8a33..5a5c797 100644 --- a/packages/tx-manifest/src/index.ts +++ b/packages/tx-manifest/src/index.ts @@ -32,6 +32,7 @@ export { estimateFeeSats } from "./fee"; // 3. The action, resolved into a reviewed plan or a refusal — and afterwards, the check // that what came back spends only what was asked for. export { type ManifestReview, isRefusal, reviewManifestAction } from "./review"; +export type { RejectToken } from "./document/refuse"; export { guardSpentInputs } from "./chain/inputGuard"; export { spentInputs } from "./chain/spentInputs"; diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index 8f3d7be..502e6a7 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -15,7 +15,7 @@ import { normaliseManifest, } from "../document/normalise"; import type { ReferenceScope } from "../document/references"; -import { buildMode, refuseUnsupported } from "../document/refuse"; +import { buildMode, type RejectToken, refuseUnsupported } from "../document/refuse"; import { type ConstructFinding, ignored, inspectConstructs } from "../document/registry"; import { covenantSites } from "../document/sites"; import { type InputRule, resolveInputRules } from "../evaluation/inputRules"; @@ -123,7 +123,12 @@ export type ManifestReview = { selected: SelectableUtxo[]; }; -export type ReviewRefusal = { reason: string; refused: true }; +export type ReviewRefusal = { + reason: string; + refused: true; + /** Which refusal this is, for a caller that has to branch rather than read. */ + reject: RejectToken; +}; export type ReviewManifestActionResult = ManifestReview | ReviewRefusal; @@ -177,7 +182,7 @@ export async function reviewManifestAction( }); if (refusal) { - return { reason: refusal.reason, refused: true }; + return { reason: refusal.reason, refused: true, reject: refusal.reject }; } const requirements = resolveActionRequirements(request, manifest); @@ -187,13 +192,21 @@ export async function reviewManifestAction( .map((entry) => (entry.keys ? `${entry.reason} (${entry.keys.join(", ")})` : entry.reason)) .join(" "); - return { reason: `This request cannot be built. ${named}`, refused: true }; + return { + reason: `This request cannot be built. ${named}`, + refused: true, + reject: "incomplete-request", + }; } const action = findAction(manifest, request.action); if (!action) { - return { reason: `The manifest declares no action named "${request.action}".`, refused: true }; + return { + reason: `The manifest declares no action named "${request.action}".`, + refused: true, + reject: "no-such-action", + }; } const declaredTypes = declaredParamTypes(action.node); @@ -213,7 +226,7 @@ export async function reviewManifestAction( }); if (!computed.ok) { - return { reason: computed.reason, refused: true }; + return { reason: computed.reason, refused: true, reject: "document-fault" }; } const scope: ReferenceScope = { @@ -243,7 +256,7 @@ export async function reviewManifestAction( }); if (!derived.ok) { - return { reason: derived.reason, refused: true }; + return { reason: derived.reason, refused: true, reject: "document-fault" }; } if (site.role === "created") { @@ -264,6 +277,7 @@ export async function reviewManifestAction( return { reason: `The state file lists no ${site.utxoType} to spend.`, refused: true, + reject: "no-utxo-to-spend", }; } @@ -277,13 +291,14 @@ export async function reviewManifestAction( return { reason: `Could not read what is at ${outpoint.txid}:${outpoint.vout}: ${String(error)}`, refused: true, + reject: "chain-read-failed", }; } const matched = covenantMatchesChain(derived.derivation, onChain.scriptPubKeyHex); if (!matched.matched) { - return { reason: matched.reason, refused: true }; + return { reason: matched.reason, refused: true, reject: "covenant-mismatch" }; } if (onChain.amountSats !== undefined && site.id) { @@ -296,6 +311,7 @@ export async function reviewManifestAction( `The ${site.utxoType} at ${outpoint.txid}:${outpoint.vout} is confidential. ` + "A covenant output cannot be, because Simplicity cannot read a confidential commitment.", refused: true, + reject: "unbuildable-utxo-type", }; } @@ -328,6 +344,7 @@ export async function reviewManifestAction( return { reason: `The wallet could not establish a fee rate, so it will not build this: ${String(error)}`, refused: true, + reject: "no-fee-rate", }; } @@ -342,7 +359,7 @@ export async function reviewManifestAction( const draft = planAction(action, { ...scope, fee: 0n }, notes); if (!draft.ok) { - return { reason: draft.reason, refused: true }; + return { reason: draft.reason, refused: true, reject: "document-fault" }; } const estimatedFee = estimateFeeSats( @@ -359,7 +376,7 @@ export async function reviewManifestAction( const plan = planAction(action, { ...scope, fee: estimatedFee }, notes); if (!plan.ok) { - return { reason: plan.reason, refused: true }; + return { reason: plan.reason, refused: true, reject: "document-fault" }; } // What the action requires of each input beyond where the money comes from: a relative @@ -367,7 +384,7 @@ export async function reviewManifestAction( const inputRules = resolveInputRules(action, { ...scope, fee: estimatedFee }, notes); if (!inputRules.ok) { - return { reason: inputRules.reason, refused: true }; + return { reason: inputRules.reason, refused: true, reject: "document-fault" }; } // The protocol's own rules about this action, checked once its amounts are known — a rule @@ -375,7 +392,7 @@ export async function reviewManifestAction( const failed = checkValidations(action, { ...scope, fee: estimatedFee }, notes); if (failed) { - return { reason: failed.reason, refused: true }; + return { reason: failed.reason, refused: true, reject: "document-fault" }; } const covenantScripts = new Map( @@ -399,6 +416,7 @@ export async function reviewManifestAction( return { reason: `Output ${planned.id} pays a covenant the wallet did not verify.`, refused: true, + reject: "covenant-mismatch", }; } @@ -418,6 +436,7 @@ export async function reviewManifestAction( return { reason: `This action must be funded from ${pinned}, and this wallet holds nothing there.`, refused: true, + reject: "no-funds-at-signing-address", }; } @@ -428,7 +447,7 @@ export async function reviewManifestAction( ); if (!selection.ok) { - return { reason: selection.reason, refused: true }; + return { reason: selection.reason, refused: true, reject: "shortfall" }; } const review: ManifestReview = { From 8671e9fd674494d1aa11ba825d5347e51deb2994 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 13:50:15 +0300 Subject: [PATCH 084/124] chore(smplx): repoint the submodule at the audited fork Five of the audit's comments applied: the Rust-side contract vocabulary with every js_name untouched, one provider accessor instead of two, new before from_mnemonic, ChangeOutput in its own file under its own name, ecdsaPublicKey on the bindings, and the compiler version read at build time instead of remembered. The wasm package is rebuilt from it. 517 extension tests pass against the new module, gate green, extension builds. --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index d925dba..26390ad 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit d925dbab4f9cb3da501e33a1e3993dad23165da5 +Subproject commit 26390adbf910d3864cc081533cbe65782971703c From efe18c11e7f36f73799c83488a07e63e3b64e11a Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 13:58:10 +0300 Subject: [PATCH 085/124] chore(smplx): repoint at the fork with the remaining audit tidying Derivation-path example, the sequence helper inside the builder it belongs to, and the binding able to express the nested witness form. The wasm is rebuilt and the extension driven against it: 517 tests, gate green. --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 26390ad..a961b05 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 26390adbf910d3864cc081533cbe65782971703c +Subproject commit a961b0575fe37a2398d102f3c1f0cc8a72ebe07f From 9901e2abb843a03856a35b70fe6a1ae616b65701 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 14:00:02 +0300 Subject: [PATCH 086/124] chore(smplx): repoint at the fork carrying its own change target Extension driven against the rebuilt module: 517 tests, gate green, builds. --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index a961b05..952aa5b 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit a961b0575fe37a2398d102f3c1f0cc8a72ebe07f +Subproject commit 952aa5b6f9c80c8e3853ece822dc42e1a11db51e From 09eddaa74793654f0e08f63823b7eb7091e5c195 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 14:28:23 +0300 Subject: [PATCH 087/124] chore(smplx): repoint at the merge with upstream dev The fork now carries upstream's dev, with the Program collision resolved once and stated in that commit. 517 extension tests green against the rebuilt module, including the pinned taproot addresses that would move if the resolution were wrong. --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 952aa5b..ccd6c8b 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 952aa5b6f9c80c8e3853ece822dc42e1a11db51e +Subproject commit ccd6c8b99112f6089cb42077c5ade19736336375 From d1c8302143fce2f32515d1229ef175c07875477f Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 14:36:34 +0300 Subject: [PATCH 088/124] docs(liquid): the funding limit is this wallet's, not the signing module's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer said 'that's not true' of the contract identity's doc, and they are the module's author. The doc blamed the module for a limit this wallet imposes: it said the module signs with one key and returns change to that key's own address, and that the SDK does not yet sign each input at its own path. It does. FinalTransaction takes a change target and PartialInput takes a derivation path per input. This method supplies one change script — the signer's own — and no paths at all, so every wallet input is signed with the default key because nothing here overrides it. Corrected in both places that carried the claim, including the account settings row a person actually reads. Lifting the limit is work in the method. --- .../liquid/application/contractIdentity.ts | 17 +++++++++++------ .../Account/components/ContractIdentityRow.tsx | 12 +++++++----- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/contractIdentity.ts b/apps/extension/src/core/chains/liquid/application/contractIdentity.ts index d244f8f..5d0bfe6 100644 --- a/apps/extension/src/core/chains/liquid/application/contractIdentity.ts +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.ts @@ -14,14 +14,19 @@ const SMPLX_NETWORKS: Record<string, string> = { /** * The one identity a contract action is signed with. * - * This is not the wallet's own address and is not interchangeable with it. Contract - * actions are signed inside the smplx module by a single key at - * `m/84h/{1|1776}h/0h/0/0`, and the module funds and returns change to that key's own - * unblinded address rather than to a wallet change address — so a covenant action can - * only spend what sits there, and only what sits there unblinded. + * This is not the wallet's own address and is not interchangeable with it. A contract + * action can be funded only from the unblinded output at this one address, and change + * returns here rather than to a wallet change address. + * + * **The limit is this wallet's, not the signing module's.** An earlier version of this + * comment blamed the module, and the module's author said so on review. It takes a change + * target and a derivation path per input; this wallet supplies one change script — the + * signer's own — and no paths at all, so every wallet input is signed with the key at + * `m/84h/{1|1776}h/0h/0/0` because that is the default nothing here overrides. Lifting the + * limit is work in this method, not in the module. * * Both values are read-only and public: an address anyone can pay, and the x-only form - * of the same key. Nothing here derives, stores or returns a secret. + * of the same key. Nothing here returns a secret. */ export type LiquidContractIdentity = { /** The unblinded address contract actions can be funded from, and where change returns. */ diff --git a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx index a20e0b7..31ae2d5 100644 --- a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx +++ b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx @@ -31,11 +31,13 @@ function Value({ hint, label, value }: { hint: string; label: string; value: str /** * The address and key that contract actions are signed with. * - * It is a separate row from the wallet's own address on purpose. Contract actions are - * signed by a single key inside the contract SDK, which funds from that key's own - * unblinded address and returns change to it — so paying a contract action from any - * other wallet address produces a transaction that cannot be signed. Until the SDK signs - * each input at its own path, saying so is more use than hiding it. + * It is a separate row from the wallet's own address on purpose. A contract action is + * funded from this one unblinded address and returns change to it, so paying one from any + * other wallet address produces a transaction that cannot be signed. Saying so is more + * use than hiding it. + * + * The limit is this wallet's rather than the contract SDK's: the SDK takes a change target + * and a derivation path per input, and this wallet supplies one address and no paths. * * Nothing here is secret: an address anyone can pay and the public form of one key. It * is read on demand rather than with the page because reading it loads the contract From 31f3d3e88cceffe8d41f3e2eea1657e4566ded93 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 15:44:04 +0300 Subject: [PATCH 089/124] chore(smplx): repoint at the fork that keeps upstream's compile-once 517 extension tests green against the rebuilt module, including the pinned taproot addresses that would move if the memoisation were wrong. --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index ccd6c8b..2024762 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit ccd6c8b99112f6089cb42077c5ade19736336375 +Subproject commit 2024762233c257925e527c19d4c44e63a13af5ca From f06c5db076fc61d7c8441442851d1ff108d6c225 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 16:15:47 +0300 Subject: [PATCH 090/124] chore(smplx): repoint at the fork whose generated artifacts compile again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with upstream dev left the code generator calling a Program signature this fork does not have, so every contract the framework generates failed to compile — ten errors across five programs. Nothing here noticed because the wallet consumes crates/wasm and never the generator, and nothing on either side compiles that output. The fork moves onto upstream's Program::new signature rather than patching the generator to call ours, and its own CI now runs on the branch it pushes to. bun run check green against a wasm rebuilt from this revision: typecheck across all three projects, oxlint, oxfmt over 630 files, 517 tests across 32 files, 0 fail. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 2024762..2099bd9 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 2024762233c257925e527c19d4c44e63a13af5ca +Subproject commit 2099bd9b15fc9479973c11558810914e303d0654 From ce578787b67caf5ee4fae9e95f8f0c219dc151fa Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 16:39:11 +0300 Subject: [PATCH 091/124] chore(smplx): repoint at the fork with its CI edits reverted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconciliation had added this branch's name to two of upstream's workflow files so their gate would run on our pushes. Maintainer, on reading it: our branch has no business in upstream's CI, and they are right — on a pull request into dev it reads as noise in somebody else's repository. It also never worked. The push landed and nothing ran, because GitHub leaves a forked repository's workflows unregistered until someone enables them by hand, which no trigger edit substitutes for. The generator fix is untouched and no code changed here, so dist/ built at 16:35 from f06c5db is still the current build for testing. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 2099bd9..90cae26 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 2099bd9b15fc9479973c11558810914e303d0654 +Subproject commit 90cae26be7692eb680f04d8134258d8a310e5c81 From 1a8d1b3e7b80c7e20b1a34cb28b7af7b3635056e Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 17:05:29 +0300 Subject: [PATCH 092/124] feat(liquid): hold the compiler version here, and follow the SDK's renamed surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork is being cut back to what upstream would take, on the maintainer's principle that smplx as it was is the authority and a capability belonging to the wallet should live in the wallet. The compiler version is such a capability: it exists so this wallet can refuse a protocol declaring a compiler it cannot reproduce, which is nothing the SDK needs to know about itself. So SMPLX_COMPILER_VERSION lives beside the smplx adapter, and a test reads the version out of the submodule's own workspace manifest and fails when the two disagree. That check is what makes a written constant safe, and it is why the constant is acceptable here where it was not in the fork: this repository runs its tests on every push and the fork has never run a workflow at all. The binding's renamed surface follows: addContractInput, dryRunContractInput, contractAddress. `covenant` stays the wallet's word, because it is the manifest format's, and the translation now happens at this boundary rather than inside the SDK. Where change goes is set on the builder rather than passed to the call that signs, which is what the reviewer asked for and is also where the fact belongs. Found while doing it, and larger than the change: bun install copies smplx_wasm_bg.wasm rather than hardlinking it, while hardlinking the glue beside it. So every suite since 13:49 ran new JavaScript against a module built before the merge with upstream — including the run recorded as proving the reconciliation, and the build the live Pay and Receive were driven from. Nothing was false about the binary those ran; they were false about which binary it was. Re-run after bun install: 521 tests green. A new assertion pins the covenant address the live runs actually put money at. It is the only value in that file tied to the chain rather than to a previous run of the same code, which is exactly the check a stale module can defeat. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- .../adapters/smplx/compilerVersion.test.ts | 34 +++++++ .../liquid/adapters/smplx/compilerVersion.ts | 18 ++++ .../adapters/smplx/loadSmplxWasm.test.ts | 97 +++++++++++++++---- .../index.test.ts | 20 +++- .../processConfidentialTransaction/index.ts | 19 ++-- smplx | 2 +- 6 files changed, 155 insertions(+), 35 deletions(-) create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.ts diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.test.ts new file mode 100644 index 0000000..8603458 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { SMPLX_COMPILER_VERSION } from "./compilerVersion"; + +// The constant is the wallet's statement about which compiler the shipped module has, and a +// wallet wrong about that refuses manifests that should have built. Nothing in a wasm blob +// says which Rust dependency produced it, so the only thing that can contradict the constant +// is the manifest the submodule is built from — which is what this reads. +describe("the compiler version the wallet claims", () => { + const workspaceManifest = join(import.meta.dir, "../../../../../../../..", "smplx", "Cargo.toml"); + + function pinnedVersion(): string { + const manifest = readFileSync(workspaceManifest, "utf8"); + const pin = /^simplicityhl\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"/m.exec(manifest); + + if (!pin) { + throw new Error(`No simplicityhl version pin found in ${workspaceManifest}`); + } + + return pin[1]; + } + + it("matches the version the submodule's workspace pins", () => { + expect(SMPLX_COMPILER_VERSION).toBe(pinnedVersion()); + }); + + // Guards the reader rather than the value: a regex that stopped matching would make the + // assertion above pass against nothing, which is the failure mode a drift test has. + it("is read from the manifest rather than defaulted", () => { + expect(pinnedVersion()).toMatch(/^\d+\.\d+/); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.ts new file mode 100644 index 0000000..20ef957 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.ts @@ -0,0 +1,18 @@ +/** + * The SimplicityHL compiler version the shipped signing module compiles contracts with. + * + * This is the wallet's fact rather than the SDK's. A protocol declares which compiler its + * contracts were built with, and the wallet refuses one it cannot reproduce — so the value + * exists to be compared against a manifest, which is a wallet concern the SDK has no reason + * to carry. It lived in the fork as a constant, then as a build script reading the workspace + * manifest, and upstream asked for both to go; keeping the fact here rather than defending it + * in someone else's crate is the smaller fork and the more honest home. + * + * Written down rather than derived at runtime because there is nothing to derive it from: the + * module is a wasm blob and the version is a Rust dependency of the crate that built it. What + * makes a written constant safe is that its drift is caught — `compilerVersion.test.ts` reads + * the pinned version out of the submodule's own manifest and fails when the two disagree, and + * this repository runs its tests on every push. That check is the whole reason this is not a + * number somebody has to remember. + */ +export const SMPLX_COMPILER_VERSION = "0.6.0"; diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index da82a04..8365339 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -61,7 +61,7 @@ describe("smplx wasm module", () => { test("derives a covenant address", () => { const contract = new bindings.Contract(PROBE_SOURCE); - const address = contract.covenantAddress("liquid-testnet"); + const address = contract.contractAddress("liquid-testnet"); expect(address.startsWith("tex1p")).toBe(true); }); @@ -75,7 +75,7 @@ describe("smplx wasm module", () => { test("rejects an unknown network by name", () => { const contract = new bindings.Contract(PROBE_SOURCE); - expect(() => contract.covenantAddress("not-a-network")).toThrow(); + expect(() => contract.contractAddress("not-a-network")).toThrow(); }); }); @@ -99,18 +99,43 @@ describe("contract parameters", () => { expect(contract.commitmentMerkleRoot()).toMatch(/^[0-9a-f]{64}$/); }); + // The one address in this file that money has actually sat at. Everything else here pins a + // value this module produced; this pins one the Liquid testnet chain holds, from the live + // runs of 2026-08-07 — `08e775d0…` paid a covenant at this address and `5c3a56a0…` spent it + // with a Simplicity witness the network accepted. + // + // It exists because a check that only compares this module against itself cannot notice the + // module being replaced. A stale copy of the wasm binary in node_modules did exactly that + // for most of a day: the JavaScript glue is hardlinked and refreshes on rebuild while the + // binary is a separate copy that only `bun install` replaces, so every suite ran new glue + // against an old module and passed. This assertion would still have passed then — the + // address was the same — which is the point: it is the one that ties a derivation to money + // rather than to a previous run of the same code. + test("derives the covenant address the live runs put money at", () => { + const contract = new bindings.Contract( + P2PK_SOURCE, + args("0xc9fda1adfd5af94ccbe2a6cd72433fc6dc1731fe3f8b3fee90ca96367ca71041"), + ); + + expect(contract.contractAddress("liquid-testnet")).toBe( + "tex1plmdx307xcw7hfewf7pmmfum0l6tkr35keugxzczc2azmqw4uzlasst2a40", + ); + + contract.free(); + }); + test("different parameters produce different covenant addresses", () => { const alice = new bindings.Contract(P2PK_SOURCE, args(ALICE)); const bob = new bindings.Contract(P2PK_SOURCE, args(BOB)); - expect(alice.covenantAddress("liquid-testnet")).not.toBe(bob.covenantAddress("liquid-testnet")); + expect(alice.contractAddress("liquid-testnet")).not.toBe(bob.contractAddress("liquid-testnet")); }); test("the same parameters produce the same covenant address", () => { const first = new bindings.Contract(P2PK_SOURCE, args(ALICE)); const second = new bindings.Contract(P2PK_SOURCE, args(ALICE)); - expect(first.covenantAddress("liquid-testnet")).toBe(second.covenantAddress("liquid-testnet")); + expect(first.contractAddress("liquid-testnet")).toBe(second.contractAddress("liquid-testnet")); }); test("refuses malformed argument JSON when the contract is constructed", () => { @@ -276,7 +301,9 @@ describe("finalising a transaction", () => { builder.addOutput(signer.scriptPubKeyHex(), 50_000n, POLICY_ASSET); - const signed = signer.finalizeTransaction(builder, FEE_RATE, signer.scriptPubKeyHex()); + builder.addChange(signer.scriptPubKeyHex()); + + const signed = signer.finalizeTransaction(builder, FEE_RATE); expect(signed.hex).toMatch(/^[0-9a-f]+$/); expect(signed.txid).toMatch(/^[0-9a-f]{64}$/); @@ -293,23 +320,45 @@ describe("finalising a transaction", () => { builder.addOutput(signer.scriptPubKeyHex(), 999_999n, POLICY_ASSET); - expect(() => signer.finalizeTransaction(builder, FEE_RATE, signer.scriptPubKeyHex())).toThrow(); + builder.addChange(signer.scriptPubKeyHex()); + + expect(() => signer.finalizeTransaction(builder, FEE_RATE)).toThrow(); builder.free(); signer.free(); }); + // The refusal moved with the change target: it is rejected when it is stated rather than + // when the transaction is signed, which is earlier and is where a caller can act on it. test("refuses a change script it cannot parse, rather than sending change nowhere", () => { const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); const builder = fundedBuilder(signer, 100_000n); builder.addOutput(signer.scriptPubKeyHex(), 50_000n, POLICY_ASSET); - expect(() => signer.finalizeTransaction(builder, FEE_RATE, "not-hex")).toThrow(); + expect(() => builder.addChange("not-hex")).toThrow(); builder.free(); signer.free(); }); + + // Unset change is the SDK's own behaviour and this fork did not change it: the module + // returns change to the signer's derived address. Asserted because removing the parameter + // made it reachable by omission rather than only by argument. + test("finalises without a change target, returning change to the signer's own address", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = fundedBuilder(signer, 100_000n); + + builder.addOutput(signer.scriptPubKeyHex(), 50_000n, POLICY_ASSET); + + const signed = signer.finalizeTransaction(builder, FEE_RATE); + + expect(signed.txid).toMatch(/^[0-9a-f]{64}$/); + + signed.free(); + builder.free(); + signer.free(); + }); }); // A covenant input is an output locked by a Simplicity program. The dry-run is what tells @@ -336,7 +385,7 @@ describe("covenant inputs and the dry-run", () => { test("takes a covenant input", () => { const builder = new bindings.TransactionBuilder(); - builder.addCovenantInput(TXID, 0, covenantTxOut(100_000n), P2PK_SOURCE, ARGS); + builder.addContractInput(TXID, 0, covenantTxOut(100_000n), P2PK_SOURCE, ARGS); expect(builder.inputCount()).toBe(1); builder.free(); @@ -346,7 +395,7 @@ describe("covenant inputs and the dry-run", () => { const builder = new bindings.TransactionBuilder(); expect(() => - builder.addCovenantInput(TXID, 0, covenantTxOut(1n), P2PK_SOURCE, ARGS, "{ not json"), + builder.addContractInput(TXID, 0, covenantTxOut(1n), P2PK_SOURCE, ARGS, "{ not json"), ).toThrow(); expect(builder.inputCount()).toBe(0); builder.free(); @@ -358,13 +407,13 @@ describe("covenant inputs and the dry-run", () => { const builder = new bindings.TransactionBuilder(); const contract = new bindings.Contract(P2PK_SOURCE, ARGS); - builder.addCovenantInput(TXID, 0, covenantTxOut(100_000n), P2PK_SOURCE, ARGS); + builder.addContractInput(TXID, 0, covenantTxOut(100_000n), P2PK_SOURCE, ARGS); builder.addOutput(contract.scriptPubKeyHex("liquid-testnet"), 90_000n, POLICY_ASSET); let outcome = "ran"; try { - builder.dryRunCovenantInput(0, "liquid-testnet"); + builder.dryRunContractInput(0, "liquid-testnet"); } catch (error) { outcome = String(error); } @@ -384,7 +433,7 @@ describe("covenant inputs and the dry-run", () => { builder.addWalletInput(TXID, 0, walletTxOut); - expect(() => builder.dryRunCovenantInput(0, "liquid-testnet")).toThrow(); + expect(() => builder.dryRunContractInput(0, "liquid-testnet")).toThrow(); builder.free(); signer.free(); @@ -393,7 +442,7 @@ describe("covenant inputs and the dry-run", () => { test("refuses to dry-run an input that does not exist", () => { const builder = new bindings.TransactionBuilder(); - expect(() => builder.dryRunCovenantInput(4, "liquid-testnet")).toThrow(); + expect(() => builder.dryRunContractInput(4, "liquid-testnet")).toThrow(); builder.free(); }); }); @@ -430,7 +479,7 @@ describe("signing a covenant that authenticates its spender", () => { try { for (let i = 0; i < covenantInputs; i += 1) { - builder.addCovenantInput( + builder.addContractInput( TXID, i, txOut(200_000n, covenantScript), @@ -449,7 +498,9 @@ describe("signing a covenant that authenticates its spender", () => { builder.addOutput(signer.scriptPubKeyHex(), 10_000n, POLICY_ASSET); } - const signed = signer.finalizeTransaction(builder, RATE, signer.scriptPubKeyHex()); + builder.addChange(signer.scriptPubKeyHex()); + + const signed = signer.finalizeTransaction(builder, RATE); const fee = signed.feeSats; signed.free(); @@ -507,7 +558,7 @@ describe("extra taproot leaves", () => { function addressWith(...leaves: string[]) { const contract = new bindings.Contract(SOURCE, undefined, JSON.stringify(leaves)); - return contract.covenantAddress("liquid-testnet"); + return contract.contractAddress("liquid-testnet"); } test("no extra leaves derives the address the module always derived", () => { @@ -569,7 +620,9 @@ describe("what a signed transaction says it spends", () => { builder.addOutput(signer.scriptPubKeyHex(), 10_000n, POLICY_ASSET); - const signed = signer.finalizeTransaction(builder, 1000, signer.scriptPubKeyHex()); + builder.addChange(signer.scriptPubKeyHex()); + + const signed = signer.finalizeTransaction(builder, 1000); const hex = signed.hex; signed.free(); @@ -644,7 +697,7 @@ describe("golden covenant addresses", () => { input.debug, ); - return contract.covenantAddress(input.network ?? "liquid-testnet"); + return contract.contractAddress(input.network ?? "liquid-testnet"); } test("the parameterised contract, on testnet", () => { @@ -779,7 +832,7 @@ describe("the simplicity-lending contracts", () => { }; expect( - new bindings.Contract(text, JSON.stringify(other), undefined, undefined).covenantAddress( + new bindings.Contract(text, JSON.stringify(other), undefined, undefined).contractAddress( "liquid-testnet", ), ).not.toBe( @@ -788,7 +841,7 @@ describe("the simplicity-lending contracts", () => { JSON.stringify(ARGUMENTS.lending), undefined, undefined, - ).covenantAddress("liquid-testnet"), + ).contractAddress("liquid-testnet"), ); }); }); @@ -818,7 +871,9 @@ describe("a transaction balances against the fee that is charged", () => { builder.addWalletInput(TXID, 0, txOut(funded, signer.scriptPubKeyHex()), undefined); builder.addOutput(signer.scriptPubKeyHex(), paid, POLICY_ASSET); - const signed = signer.finalizeTransaction(builder, rate, signer.scriptPubKeyHex()); + builder.addChange(signer.scriptPubKeyHex()); + + const signed = signer.finalizeTransaction(builder, rate); const fee = signed.feeSats; signed.free(); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 04ec805..1878e49 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -123,7 +123,7 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { ({ compilerVersion: () => "0.6.0", Contract: class { - covenantAddress() { + contractAddress() { return DERIVED; } // Held across the wasm boundary, so the method releases it. A substitute @@ -138,8 +138,17 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { // through a green suite (DISC-138), so the rule is now the module's own: what // it decodes, this decodes. TransactionBuilder: class { + change: string | undefined; spends: { txid: string; vout: number }[] = []; - addCovenantInput(txid: string, vout: number, txOutHex: string) { + // The change target moved onto the builder, and so did the parse that rejects + // one it cannot read. Recorded rather than swallowed, so a method that stopped + // stating where change goes fails here instead of sending it to the module's + // own default in silence. + addChange(scriptPubKeyHex: string) { + requireHex("change script", scriptPubKeyHex); + this.change = scriptPubKeyHex; + } + addContractInput(txid: string, vout: number, txOutHex: string) { requireHex("covenant input's previous output", txOutHex); requireTxid(txid); this.spends.push({ txid, vout }); @@ -157,11 +166,12 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { }, WalletSigner: class { finalizeTransaction( - builder: { spends: { txid: string; vout: number }[] }, + builder: { change?: string; spends: { txid: string; vout: number }[] }, _feeRateSatsPerKvb: number, - changeScriptPubKeyHex: string, ) { - requireHex("change script", changeScriptPubKeyHex); + if (builder.change === undefined) { + throw new Error("The transaction was finalised without a change target."); + } return { feeSats: 500n, diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 7859cae..099964a 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -19,6 +19,7 @@ import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; import { toScriptPubKeyHex } from "../../../adapters/lwk/wallet/toScriptPubKeyHex"; import { withAccountMnemonic } from "../../../adapters/lwk/wallet/withAccountMnemonic"; +import { SMPLX_COMPILER_VERSION } from "../../../adapters/smplx/compilerVersion"; import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; import { LIQUID_WALLET_RPC_METHODS } from "../../../domain/LiquidRpc"; @@ -166,7 +167,7 @@ export const createProcessLiquidConfidentialTransaction = ( // Covenant inputs first: the manifest's own input order is what a covenant // introspects, and wallet inputs are the wallet's addition to it. for (const covenant of review.covenantInputs) { - builder.addCovenantInput( + builder.addContractInput( covenant.txid, covenant.vout, covenant.txOutHex, @@ -189,11 +190,13 @@ export const createProcessLiquidConfidentialTransaction = ( builder.addOutput(output.scriptPubKeyHex, output.sats, account.rawPolicyAssetId); } - const result = signer.finalizeTransaction( - builder, - review.feeRateSatsPerKvb, - signer.scriptPubKeyHex(), - ); + // Where change goes is a fact about this transaction, so it is set on the + // builder rather than passed to the call that signs it. Unset, the module + // returns change to the signer's own derived address, which this wallet does + // watch today but only because the signing path is limited to one index. + builder.addChange(signer.scriptPubKeyHex()); + + const result = signer.finalizeTransaction(builder, review.feeRateSatsPerKvb); const extracted = { feeSats: result.feeSats.toString(), transactionHex: result.hex, @@ -270,14 +273,14 @@ export const createProcessLiquidConfidentialTransaction = ( try { return { - address: contract.covenantAddress(target), + address: contract.contractAddress(target), scriptPubKeyHex: contract.scriptPubKeyHex(target), }; } finally { contract.free(); } }, - compilerVersion: smplx.compilerVersion(), + compilerVersion: SMPLX_COMPILER_VERSION, policyAsset: account.rawPolicyAssetId, scriptPubKeyOf: ({ argumentsJson, source }) => new smplx.Contract(source, argumentsJson).scriptPubKeyHex(network), diff --git a/smplx b/smplx index 90cae26..4fab225 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 90cae26be7692eb680f04d8134258d8a310e5c81 +Subproject commit 4fab225790e214e9d4b08ec77750e74c8d8c4752 From de760b930dc2f35824fe4b6deb696a40671a514e Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 21:38:05 +0300 Subject: [PATCH 093/124] chore(smplx): follow upstream's own branch, and stop following the abandoned one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer closed the fork's pull request and reopened the work from a branch inside upstream itself — BlockstreamResearch/smplx:wasm, off dev, carrying this fork's whole contribution as two commits of his own with his edits on top. That branch is the authority now and the fork's humid/wasm-wallet-integration is not. Nothing had to be carried over. What the two branches contain differs in four doc-comment hunks — trailing whitespace his formatter stripped, a missing trailing comma, and a doc example replaced by the prose line it had before. No exported name, type or signature differs, so the wallet's side needed no change at all. The submodule now tracks humid/wasm, cut from upstream/wasm and carrying one commit on top: the workspace clippy gate, which upstream's branch does not pass. Verified against a genuinely current module. bun's copy of the wasm payload was stale again — the intermediate wasm-pack writes before wasm-opt, 11.78 MB against the 6.86 MB in pkg — so bun install was re-run and both are one inode again. Then bun run check: 521 tests, three typecheck projects, oxfmt over 632 files, green. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- .gitmodules | 2 +- smplx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 9e21e84..dd785ef 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,4 +5,4 @@ [submodule "smplx"] path = smplx url = https://github.com/lukachi/smplx.git - branch = humid/wasm-wallet-integration + branch = humid/wasm diff --git a/smplx b/smplx index 4fab225..92673ad 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 4fab225790e214e9d4b08ec77750e74c8d8c4752 +Subproject commit 92673adc95a590352dfd2bb81a873fc3ac3aa40a From 3408e7f56c10c8483f79ad6b4beb70283eea8f0f Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 23:11:39 +0300 Subject: [PATCH 094/124] feat(web): read a manifest by running the reader, not by describing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person holding a txManifest document cannot find out what this wallet makes of it short of connecting a wallet and trying. Three questions have no answer from outside: which older spellings still work, which fields the wallet acts on rather than tolerates, and what it would refuse before it touches money. All three are answered by code that already runs in a browser. @humid/tx-manifest depends on a hashing library and a schema library, holds no keys, opens no connection and remembers nothing between calls, and the web harness already depended on it. So the harness gains a view that runs it on pasted text. The alternative was a hand-written JSON Schema, and it cannot do this. The runtime classifies each field five ways and a schema has two states; the arithmetic inside validation rules is a grammar no schema expresses; and a description maintained by hand is wrong the first time someone adds a field while still looking authoritative. Running the reader cannot drift from the reader. The package publishes one function rather than the three readers behind it. Its entry point states that a module absent from it is private and that publishing one is a deliberate act; spending that three times for one caller, and letting the page reach three levels into the package, is not what it invites. The traversal those readers share was extracted so refusing and reporting walk one table — a position added to one and forgotten in the other is a field that refuses without appearing. The package's own 393 tests are unchanged by that extraction, which is what says the wallet's refusal path still behaves as it did. Placement was settled by evidence rather than taste. The harness has no router at all, so there is no path to add; and its developer surface is replaced wholesale by a not-detected notice when no wallet is installed, which would hide an offline tool exactly when it is most useful. Hence a third view, rendered before any wallet check. What the page will not do is claim more than it checked. Only eight of the wallet's seventeen refusals can be decided from a document, and this page holds neither a compiler version nor a policy asset, so three of those eight go unasked. All twelve unreached checks are named on screen, beside the result, at the same weight — the absence of a refusal is printed as the absence of a refusal and never as a promise that a wallet would build the action. Two things this turned up. The harness typechecked against ES2022 while the package uses ES2023 methods, invisible until now because everything it had ever imported from that package was JSON, and JSON has no types. And it could not typecheck a test at all, having never had one; the one-line fix already exists twice here. 33 new tests, 554 green across 36 files. Three typechecks, lint, format and the web build clean. Nobody has looked at the rendered page yet, and four of the seven acceptance criteria are statements about what a reader is shown. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- apps/web/src/App.tsx | 36 ++-- apps/web/src/app/home/index.tsx | 21 +- .../manifest/components/ConstructTable.tsx | 63 ++++++ .../app/manifest/components/RefusalPanel.tsx | 80 ++++++++ .../app/manifest/components/RewriteList.tsx | 34 ++++ .../manifest/components/groupByState.test.ts | 62 ++++++ .../app/manifest/components/groupByState.ts | 35 ++++ apps/web/src/app/manifest/index.tsx | 129 ++++++++++++ .../web/src/app/manifest/readDocument.test.ts | 58 ++++++ apps/web/src/app/manifest/readDocument.ts | 44 ++++ apps/web/src/bun-test-env.d.ts | 10 + apps/web/tsconfig.app.json | 9 +- .../tx-manifest/src/document/inspect.test.ts | 188 ++++++++++++++++++ packages/tx-manifest/src/document/inspect.ts | 110 ++++++++++ packages/tx-manifest/src/document/refuse.ts | 87 ++++++++ packages/tx-manifest/src/document/registry.ts | 150 ++++++++++---- packages/tx-manifest/src/index.ts | 17 ++ 17 files changed, 1082 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/app/manifest/components/ConstructTable.tsx create mode 100644 apps/web/src/app/manifest/components/RefusalPanel.tsx create mode 100644 apps/web/src/app/manifest/components/RewriteList.tsx create mode 100644 apps/web/src/app/manifest/components/groupByState.test.ts create mode 100644 apps/web/src/app/manifest/components/groupByState.ts create mode 100644 apps/web/src/app/manifest/index.tsx create mode 100644 apps/web/src/app/manifest/readDocument.test.ts create mode 100644 apps/web/src/app/manifest/readDocument.ts create mode 100644 apps/web/src/bun-test-env.d.ts create mode 100644 packages/tx-manifest/src/document/inspect.test.ts create mode 100644 packages/tx-manifest/src/document/inspect.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7719dce..511b91c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,30 +3,40 @@ import { useState } from "react"; import Dashboard from "@/app/dashboard"; import Home from "@/app/home"; +import ManifestInspector from "@/app/manifest"; import { Button } from "@/components/ui/button"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -type View = "home" | "developer"; +type View = "developer" | "home" | "manifest"; export function App() { const [view, setView] = useState<View>("home"); return ( <TooltipProvider> - {view === "home" ? ( - <Home onOpenDeveloper={() => setView("developer")} /> - ) : ( - <div className="flex min-h-svh flex-col"> - <div className="mx-auto flex w-full max-w-4xl px-4 pt-4"> - <Button variant="ghost" size="sm" onClick={() => setView("home")}> - <ChevronLeftIcon /> - Back to app - </Button> + {(() => { + if (view === "home") { + return ( + <Home + onOpenDeveloper={() => setView("developer")} + onOpenManifestInspector={() => setView("manifest")} + /> + ); + } + + return ( + <div className="flex min-h-svh flex-col"> + <div className="mx-auto flex w-full max-w-4xl px-4 pt-4"> + <Button variant="ghost" size="sm" onClick={() => setView("home")}> + <ChevronLeftIcon /> + Back to app + </Button> + </div> + {view === "developer" ? <Dashboard /> : <ManifestInspector />} </div> - <Dashboard /> - </div> - )} + ); + })()} <Toaster /> </TooltipProvider> ); diff --git a/apps/web/src/app/home/index.tsx b/apps/web/src/app/home/index.tsx index d9e65ff..71ec357 100644 --- a/apps/web/src/app/home/index.tsx +++ b/apps/web/src/app/home/index.tsx @@ -8,7 +8,13 @@ import { HomeActions } from "./components/HomeActions"; * The product Home: an identity-first hero (network, "signed in as", balance) with a row of primary * actions. A thin consumer of {@link useHumidContext} — all wallet plumbing lives in the context. */ -export default function Home({ onOpenDeveloper }: { onOpenDeveloper: () => void }) { +export default function Home({ + onOpenDeveloper, + onOpenManifestInspector, +}: { + onOpenDeveloper: () => void; + onOpenManifestInspector: () => void; +}) { const { hasProvider, isConnected } = useHumidContext(); return ( @@ -21,7 +27,10 @@ export default function Home({ onOpenDeveloper }: { onOpenDeveloper: () => void <HeroCard /> {hasProvider && isConnected ? <HomeActions /> : null} - <div className="mt-auto flex justify-center pt-6"> + {/* The inspector sits beside Developer rather than inside it: the cards there are all + ways of driving a wallet and disappear when none is installed, which is exactly + when reading a document by itself is most useful. */} + <div className="mt-auto flex justify-center gap-1 pt-6"> <Button variant="ghost" size="sm" @@ -30,6 +39,14 @@ export default function Home({ onOpenDeveloper }: { onOpenDeveloper: () => void > Developer </Button> + <Button + variant="ghost" + size="sm" + className="text-muted-foreground text-xs" + onClick={onOpenManifestInspector} + > + Manifest inspector + </Button> </div> </div> ); diff --git a/apps/web/src/app/manifest/components/ConstructTable.tsx b/apps/web/src/app/manifest/components/ConstructTable.tsx new file mode 100644 index 0000000..3e673d5 --- /dev/null +++ b/apps/web/src/app/manifest/components/ConstructTable.tsx @@ -0,0 +1,63 @@ +import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; + +import { Badge } from "@/components/ui/badge"; + +import { groupByState } from "./groupByState"; + +/** + * What each state means, in the words a protocol author would use. + * + * The state names are the runtime's; these sentences are what a person reading the table + * actually needs, and they say what happens rather than what the field is called. + */ +const MEANING: Record<ConstructState, { badge: BadgeVariant; sentence: string }> = { + "acted-on": { badge: "default", sentence: "Read, and it changes what gets signed." }, + "never-read": { + badge: "ghost", + sentence: "Known to the format and read by nothing, here or in the reference implementation.", + }, + shown: { badge: "secondary", sentence: "Read, and shown to a person. It decides nothing." }, + unimplemented: { + badge: "destructive", + sentence: "The format defines it and this wallet does not implement it.", + }, + unrecognised: { + badge: "destructive", + sentence: "No specification this wallet knows describes this field here.", + }, +}; + +type BadgeVariant = "default" | "destructive" | "ghost" | "secondary"; + +export function ConstructTable({ constructs }: { constructs: ConstructReport[] }) { + if (constructs.length === 0) { + return <p className="text-muted-foreground text-sm">This document declares no fields.</p>; + } + + const grouped = groupByState(constructs); + + return ( + <div className="flex flex-col gap-6"> + {grouped.map((group) => ( + <section key={group.state} className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Badge variant={MEANING[group.state].badge}>{group.state}</Badge> + <span className="text-muted-foreground text-xs">{MEANING[group.state].sentence}</span> + </div> + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <tbody> + {group.entries.map((report) => ( + <tr key={`${report.at}/${report.key}`} className="border-border/50 border-b"> + <td className="py-1 pr-4 font-mono">{report.key}</td> + <td className="text-muted-foreground py-1">{report.at}</td> + </tr> + ))} + </tbody> + </table> + </div> + </section> + ))} + </div> + ); +} diff --git a/apps/web/src/app/manifest/components/RefusalPanel.tsx b/apps/web/src/app/manifest/components/RefusalPanel.tsx new file mode 100644 index 0000000..3c0c653 --- /dev/null +++ b/apps/web/src/app/manifest/components/RefusalPanel.tsx @@ -0,0 +1,80 @@ +import type { ManifestInspection } from "@humid/tx-manifest"; + +import { Badge } from "@/components/ui/badge"; + +/** + * The refusal, and — always beside it — what was never asked. + * + * The absence of a refusal here is the most misreadable thing on this page. A document can + * be flawless in every way a document can be judged and still be unbuildable for want of + * money, a fee rate, or the covenant actually being where the state file says. So the + * unreached checks are not a footnote: they are rendered in the same panel, at the same + * weight, whether or not a refusal was found. + */ +export function RefusalPanel({ + inspection, +}: { + inspection: Pick<ManifestInspection, "refusal" | "skipped" | "unreachable">; +}) { + return ( + <div className="flex flex-col gap-4"> + {(() => { + if (!inspection.refusal) { + return ( + <p className="text-sm"> + No refusal that a document alone can decide. This is not a statement that the wallet + would build an action from it. + </p> + ); + } + + return ( + <div className="flex flex-col gap-2"> + <Badge variant="destructive" className="font-mono"> + {inspection.refusal.reject} + </Badge> + <p className="text-sm">{inspection.refusal.reason}</p> + </div> + ); + })()} + + {inspection.skipped.length > 0 && ( + <Unasked + heading="Not checked, because this page was not told what it needs" + explanation="The compiler check needs the single SimplicityHL version a wallet ships, and this page ships none." + tokens={inspection.skipped} + /> + )} + + <Unasked + heading="Not checkable from a document at all" + explanation="Each of these is decided against money, a chain read, a fee rate or a filled request. Reading a document establishes nothing about any of them." + tokens={inspection.unreachable} + /> + </div> + ); +} + +function Unasked({ + explanation, + heading, + tokens, +}: { + explanation: string; + heading: string; + tokens: readonly string[]; +}) { + return ( + <section className="flex flex-col gap-2"> + <h3 className="text-sm font-medium">{heading}</h3> + <p className="text-muted-foreground text-xs">{explanation}</p> + <div className="flex flex-wrap gap-1"> + {tokens.map((token) => ( + <Badge key={token} variant="outline" className="font-mono"> + {token} + </Badge> + ))} + </div> + </section> + ); +} diff --git a/apps/web/src/app/manifest/components/RewriteList.tsx b/apps/web/src/app/manifest/components/RewriteList.tsx new file mode 100644 index 0000000..8627580 --- /dev/null +++ b/apps/web/src/app/manifest/components/RewriteList.tsx @@ -0,0 +1,34 @@ +import type { NormalisationNote } from "@humid/tx-manifest"; + +export function RewriteList({ rewrites }: { rewrites: NormalisationNote[] }) { + if (rewrites.length === 0) { + return ( + <p className="text-muted-foreground text-sm"> + Nothing was rewritten. This document is written in the format's current spelling. + </p> + ); + } + + return ( + <div className="flex flex-col gap-3"> + <p className="text-muted-foreground text-sm"> + The wallet accepted these older spellings and renamed them before reading further. A + document needing this is from an earlier generation of the format — it still works, and + nothing about it says which generation it is. + </p> + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <tbody> + {rewrites.map((note) => ( + <tr key={`${note.at}/${note.found}`} className="border-border/50 border-b"> + <td className="py-1 pr-4 font-mono line-through opacity-60">{note.found}</td> + <td className="py-1 pr-4 font-mono">{note.canonical}</td> + <td className="text-muted-foreground py-1">{note.at}</td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + ); +} diff --git a/apps/web/src/app/manifest/components/groupByState.test.ts b/apps/web/src/app/manifest/components/groupByState.test.ts new file mode 100644 index 0000000..06958c5 --- /dev/null +++ b/apps/web/src/app/manifest/components/groupByState.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; + +import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; + +import { groupByState } from "./groupByState"; + +// AC-03's ordering half. What each field is comes from the package and is tested there; the +// order a person meets them in is this surface's own decision, and the reason it is worth +// asserting is that getting it wrong is invisible — a table still renders, with the field +// that would stop the build buried under thirty that would not. + +function report(state: ConstructState, key: string = state): ConstructReport { + return { at: "manifest", key, state }; +} + +describe("the order fields are shown in", () => { + test("leads with what no specification describes, and trails with what nothing reads", () => { + const grouped = groupByState([ + report("never-read"), + report("shown"), + report("acted-on"), + report("unimplemented"), + report("unrecognised"), + ]); + + expect(grouped.map((group) => group.state)).toEqual([ + "unrecognised", + "unimplemented", + "acted-on", + "shown", + "never-read", + ]); + }); + + test("shows no heading for a state this document does not use", () => { + const grouped = groupByState([report("acted-on")]); + + expect(grouped).toHaveLength(1); + expect(grouped[0]?.state).toBe("acted-on"); + }); + + test("keeps every field, so nothing is grouped away", () => { + const constructs = [ + report("acted-on", "chain"), + report("acted-on", "utxo_types"), + report("shown", "description"), + ]; + + const kept = groupByState(constructs).flatMap((group) => group.entries); + + expect(kept).toHaveLength(3); + expect(kept.map((entry) => entry.key).toSorted()).toEqual([ + "chain", + "description", + "utxo_types", + ]); + }); + + test("a document declaring nothing groups into nothing", () => { + expect(groupByState([])).toEqual([]); + }); +}); diff --git a/apps/web/src/app/manifest/components/groupByState.ts b/apps/web/src/app/manifest/components/groupByState.ts new file mode 100644 index 0000000..bc187dd --- /dev/null +++ b/apps/web/src/app/manifest/components/groupByState.ts @@ -0,0 +1,35 @@ +import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; + +/** + * The order a reader wants: what stops the build first, then what merely is. + * + * `unrecognised` leads because it is the one state that means nobody has ever specified this + * field here, and `never-read` trails because knowing a field is deliberately ignored is the + * least urgent thing this table says. + */ +const ORDER: ConstructState[] = [ + "unrecognised", + "unimplemented", + "acted-on", + "shown", + "never-read", +]; + +export type ConstructGroup = { + entries: ConstructReport[]; + state: ConstructState; +}; + +/** + * Groups one document's fields by what the wallet does with them, in reading order. + * + * A function rather than a few lines inside the component because it is the only decision + * that surface makes: everything else there is layout. There is no DOM in this repository's + * tests, so a decision left inside JSX is a decision nothing can check. + */ +export function groupByState(constructs: ConstructReport[]): ConstructGroup[] { + return ORDER.map((state) => ({ + entries: constructs.filter((report) => report.state === state), + state, + })).filter((group) => group.entries.length > 0); +} diff --git a/apps/web/src/app/manifest/index.tsx b/apps/web/src/app/manifest/index.tsx new file mode 100644 index 0000000..d869b0e --- /dev/null +++ b/apps/web/src/app/manifest/index.tsx @@ -0,0 +1,129 @@ +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; + +import { ConstructTable } from "./components/ConstructTable"; +import { RefusalPanel } from "./components/RefusalPanel"; +import { RewriteList } from "./components/RewriteList"; +import { readDocument } from "./readDocument"; + +/** + * What this wallet makes of a txManifest document, without building anything from it. + * + * The three panels are the three questions a protocol author cannot answer from outside: + * which older spellings still work, which fields this wallet acts on rather than tolerates, + * and what it would refuse before it touches money. All three come from `@humid/tx-manifest` + * — the same package the wallet itself reads a document with — so this page cannot describe + * a parser that differs from the one that runs. + * + * It connects to nothing. There is no wallet here, no chain read and no request, which is + * both the point and the limit: see {@link RefusalPanel} for what that costs. + */ +export default function ManifestInspector() { + const [text, setText] = useState(""); + const document = useMemo(() => readDocument(text), [text]); + + return ( + <div className="mx-auto flex min-h-svh w-full max-w-4xl flex-col gap-6 p-4 md:p-6"> + <Card> + <CardHeader> + <CardTitle>Manifest inspector</CardTitle> + <CardDescription> + Paste a txManifest document. Nothing is sent anywhere and no wallet is needed — this + runs the same reader the wallet uses, here in the page. + </CardDescription> + </CardHeader> + <CardContent className="flex flex-col gap-3"> + <Textarea + value={text} + onChange={(event) => setText(event.target.value)} + placeholder="{ }" + spellCheck={false} + className="min-h-48 font-mono text-xs" + aria-label="Manifest document" + /> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setText(JSON.stringify(p2pkManifest, null, 2))} + > + Load the p2pk example + </Button> + <Button variant="ghost" size="sm" onClick={() => setText("")} disabled={text === ""}> + Clear + </Button> + </div> + </CardContent> + </Card> + + {(() => { + if (document.kind === "empty") { + return null; + } + + if (document.kind === "unreadable") { + return ( + <Panel title="Not JSON" description="Nothing could be read from this text."> + <p className="text-sm">{document.reason}</p> + </Panel> + ); + } + + if (!document.ok) { + return ( + <Panel title="Not a manifest" description="This is JSON, and it is not a document."> + <p className="text-sm">{document.reason}</p> + </Panel> + ); + } + + return ( + <> + <Panel + title="What the wallet would refuse" + description="Checked before anything is built, and only against the document itself." + > + <RefusalPanel inspection={document} /> + </Panel> + <Panel + title="What each field is" + description="Every field this document declares, against the position it sits in." + > + <ConstructTable constructs={document.constructs} /> + </Panel> + <Panel + title="What was rewritten" + description="Spellings from earlier generations of the format, renamed on the way in." + > + <RewriteList rewrites={document.rewrites} /> + </Panel> + </> + ); + })()} + </div> + ); +} + +function Panel({ + children, + description, + title, +}: { + children: React.ReactNode; + description: string; + title: string; +}) { + return ( + <Card> + <CardHeader> + <CardTitle>{title}</CardTitle> + <CardDescription>{description}</CardDescription> + </CardHeader> + <CardContent>{children}</CardContent> + </Card> + ); +} diff --git a/apps/web/src/app/manifest/readDocument.test.ts b/apps/web/src/app/manifest/readDocument.test.ts new file mode 100644 index 0000000..5d83b57 --- /dev/null +++ b/apps/web/src/app/manifest/readDocument.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; + +import { readDocument } from "./readDocument"; + +// The page's own half of AC-07. The package decides whether parsed JSON is a document; this +// decides what happens to text that never became JSON, which is most of what a person pastes. +// The two failures need different sentences — a truncated document is a syntax problem and +// the wrong file is not — so the split is tested rather than assumed. + +describe("what the textarea currently holds", () => { + test("an empty box is not a fault to report", () => { + expect(readDocument("").kind).toBe("empty"); + }); + + test("whitespace alone is still an empty box", () => { + expect(readDocument(" \n\t ").kind).toBe("empty"); + }); + + test("text that never became JSON is unreadable, and says why", () => { + const result = readDocument("{ actions: }"); + + expect(result.kind).toBe("unreadable"); + expect(result.kind === "unreadable" && result.reason.length).toBeGreaterThan(0); + }); + + test("JSON that is not a document is read, and refused as one", () => { + const result = readDocument('"a string"'); + + expect(result.kind).toBe("read"); + expect(result.kind === "read" && result.ok).toBe(false); + }); + + test("a document is read, and carries all three answers", () => { + const result = readDocument('{ "compose_version": "1.0", "chain": "liquid" }'); + + expect(result.kind).toBe("read"); + + if (result.kind !== "read" || !result.ok) { + throw new Error("expected a readable document"); + } + + expect(result.rewrites).toHaveLength(1); + expect(result.constructs.length).toBeGreaterThan(0); + expect(result.unreachable).toHaveLength(9); + }); + + test("never asks for the checks it holds no values for", () => { + const result = readDocument('{ "chain": "liquid" }'); + + if (result.kind !== "read" || !result.ok) { + throw new Error("expected a readable document"); + } + + // The page ships no compiler version and no policy asset, and reports that rather than + // supplying a stand-in — which would turn "not checked" into "checked and fine". + expect(result.skipped).toEqual(["foreign-compiler", "foreign-asset", "unbuildable-utxo-type"]); + }); +}); diff --git a/apps/web/src/app/manifest/readDocument.ts b/apps/web/src/app/manifest/readDocument.ts new file mode 100644 index 0000000..8edbdff --- /dev/null +++ b/apps/web/src/app/manifest/readDocument.ts @@ -0,0 +1,44 @@ +import { inspectManifestDocument, type InspectManifestResult } from "@humid/tx-manifest"; + +/** Nothing has been pasted yet, which is not a fault to report. */ +export type EmptyDocument = { kind: "empty" }; + +/** The text is not JSON at all, which the parser reports better than we could. */ +export type UnreadableDocument = { kind: "unreadable"; reason: string }; + +export type ReadDocument = + | EmptyDocument + | UnreadableDocument + | ({ kind: "read" } & InspectManifestResult); + +/** + * Turns whatever is in the textarea into one of three outcomes. + * + * The split between "not JSON" and "JSON but not a manifest" is deliberate: a person pasting + * a truncated document and a person pasting the wrong file need different sentences, and + * only the first is a syntax problem. The second is the package's own judgement and is + * carried through unchanged rather than restated here. + */ +export function readDocument(text: string): ReadDocument { + const trimmed = text.trim(); + + if (trimmed === "") { + return { kind: "empty" }; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(trimmed); + } catch (error) { + return { + kind: "unreadable", + reason: error instanceof Error ? error.message : "This is not JSON.", + }; + } + + // No compiler version and no policy asset, deliberately. This page ships neither, and a + // stand-in for either would turn "not checked" into "checked and fine" — the package + // reports both as skipped, and the page prints that. + return { kind: "read", ...inspectManifestDocument(parsed) }; +} diff --git a/apps/web/src/bun-test-env.d.ts b/apps/web/src/bun-test-env.d.ts new file mode 100644 index 0000000..843aa50 --- /dev/null +++ b/apps/web/src/bun-test-env.d.ts @@ -0,0 +1,10 @@ +/// <reference types="bun-types" /> + +// Makes `bun:test` resolvable to `tsc`, which the test files import from. Same arrangement, +// and the same reason, as `apps/extension/src/bun-test-env.d.ts` and +// `packages/bun-test-env.d.ts`: `@types/bun` re-exports `bun-types` and is supposed to be +// picked up automatically, but is not under this project's configuration. +// +// It exists because until the manifest inspector, this app had no tests at all. + +export {}; diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 20b5831..03273d0 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -1,9 +1,14 @@ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", + /* ES2023 rather than the Vite template's ES2022: @humid/tx-manifest uses toSorted and + toReversed, and this app is the first thing here to import its TypeScript rather than + only a JSON fixture from it. The repository root is on ESNext; this is raised to the + least that reads the package rather than to that, so what this app may use stays a + deliberate choice. */ + "target": "ES2023", "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "types": ["vite/client"], "skipLibCheck": true, diff --git a/packages/tx-manifest/src/document/inspect.test.ts b/packages/tx-manifest/src/document/inspect.test.ts new file mode 100644 index 0000000..f12aaa2 --- /dev/null +++ b/packages/tx-manifest/src/document/inspect.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test"; + +import flatManifest from "../__fixtures__/p2pk.manifest.json"; +import { inspectManifestDocument } from "./inspect"; +import type { ConstructState } from "./registry"; + +// Expectations come from the criteria this surface exists to meet, not from what the readers +// behind it happen to return. The sharpest is AC-04: the absence of a refusal must never be +// readable as a promise that a wallet would build the action, because only eight of the +// seventeen refusals can be decided from a document at all. + +const flat = flatManifest as unknown as Record<string, unknown>; + +function inspect(document: unknown, options = {}) { + const result = inspectManifestDocument(document, options); + + if (!result.ok) { + throw new Error(`expected a readable document, got: ${result.reason}`); + } + + return result; +} + +function stateOf(document: unknown, key: string): ConstructState | undefined { + return inspect(document).constructs.find((report) => report.key === key)?.state; +} + +// AC-07. A person pastes text, so most of what arrives is not a manifest. +describe("a document it cannot read", () => { + test("says so for text that parsed to a string", () => { + const result = inspectManifestDocument("not a manifest"); + + expect(result.ok).toBe(false); + expect(result.ok === false && result.reason).toContain("a string"); + }); + + test("distinguishes an array, which is JSON and is not a manifest", () => { + const result = inspectManifestDocument([{ actions: {} }]); + + expect(result.ok).toBe(false); + expect(result.ok === false && result.reason).toContain("array"); + }); + + test("says null rather than an object, which is what typeof would have called it", () => { + const result = inspectManifestDocument(null); + + expect(result.ok).toBe(false); + expect(result.ok === false && result.reason).toContain("null"); + }); + + test("reads an empty object, which is a document that declares nothing", () => { + expect(inspect({}).constructs).toEqual([]); + }); +}); + +// AC-03. Four states plus the fifth the criterion did not count: a key no site lists. +describe("what each declared construct is", () => { + test("a construct that decides what gets signed is acted on", () => { + expect(stateOf(flat, "utxo_types")).toBe("acted-on"); + }); + + test("a description is shown to a person and decides nothing", () => { + expect(stateOf(flat, "description")).toBe("shown"); + }); + + test("a construct the format defines and this runtime does not is unimplemented", () => { + expect(stateOf({ actions: { Pay: { args: { a: 1 } } } }, "args")).toBe("unimplemented"); + }); + + test("a construct known and deliberately read by nothing is never-read", () => { + expect(stateOf(flat, "attestation_version")).toBe("never-read"); + }); + + test("a construct no site lists is unrecognised, which is not one of the four", () => { + expect(stateOf({ nobody_has_ever_seen_this: 1 }, "nobody_has_ever_seen_this")).toBe( + "unrecognised", + ); + }); + + test("names the position in the document's own terms, not as a path", () => { + const report = inspect({ actions: { Pay: { args: {} } } }).constructs.find( + (entry) => entry.key === "args", + ); + + expect(report?.at).toBe("action Pay"); + }); + + test("reports handled constructs too, which the refusal reader does not", () => { + expect(inspect(flat).constructs.some((report) => report.state === "acted-on")).toBe(true); + }); +}); + +// AC-02. A rewrite is a document from an older generation of the format, and saying so is the +// point: silently accepting one hides that the format moved. +describe("what an older spelling was rewritten to", () => { + test("reports the rename with where it was found and both names", () => { + const [rewrite] = inspect({ compose_version: "1.0" }).rewrites; + + expect(rewrite).toEqual({ + at: "manifest", + canonical: "manifest_version", + found: "compose_version", + }); + }); + + test("reports a rename inside an action against that action", () => { + const rewrite = inspect({ actions: { Pay: { deploy: true } } }).rewrites.find( + (note) => note.canonical === "is_constructor", + ); + + expect(rewrite?.at).toBe("action Pay"); + expect(rewrite?.found).toBe("deploy"); + }); + + test("a document in the current spelling reports no rewrites at all", () => { + expect(inspect({ manifest_version: "1.0" }).rewrites).toEqual([]); + }); +}); + +// AC-04 and AC-05 together. The refusal is only ever half the answer, and the half that is +// missing has to arrive with it. +describe("what it would refuse on, and what it never asked", () => { + test("reports the first refusal with its stable token", () => { + const result = inspect({ chain: "bitcoin" }); + + expect(result.refusal?.reject).toBe("foreign-chain"); + expect(result.refusal?.reason).toContain("bitcoin"); + }); + + test("a construct nobody lists refuses as unrecognised rather than unimplemented", () => { + expect(inspect({ nobody_has_ever_seen_this: 1 }).refusal?.reject).toBe( + "unrecognised-construct", + ); + }); + + test("always names the refusals no reading of a document can reach", () => { + const { unreachable } = inspect(flat); + + expect(unreachable).toContain("covenant-mismatch"); + expect(unreachable).toContain("shortfall"); + expect(unreachable).toContain("no-fee-rate"); + expect(unreachable).toHaveLength(9); + }); + + test("names the reachable checks it could not perform, rather than passing them", () => { + expect(inspect(flat).skipped).toEqual([ + "foreign-compiler", + "foreign-asset", + "unbuildable-utxo-type", + ]); + }); + + test("skips nothing once the caller supplies what those checks need", () => { + const result = inspect(flat, { compilerVersion: "0.4.0", policyAsset: "lbtc" }); + + expect(result.skipped).toEqual([]); + }); + + test("refuses a compiler it does not have, once it has been told which it has", () => { + const result = inspect( + { simplicity_hl_version: "9.9.9" }, + { compilerVersion: "0.4.0", policyAsset: "lbtc" }, + ); + + expect(result.refusal?.reject).toBe("foreign-compiler"); + }); + + test("does not refuse a compiler it was never told about", () => { + expect(inspect({ simplicity_hl_version: "9.9.9" }).refusal).toBeUndefined(); + }); +}); + +// The reason this is a package function rather than a page: the page must not be able to +// reach a network, and neither must this. +describe("what it does not do", () => { + test("leaves the document it was given untouched", () => { + const document = { actions: { Pay: { deploy: true } }, compose_version: "1.0" }; + const before = JSON.stringify(document); + + inspectManifestDocument(document); + + expect(JSON.stringify(document)).toBe(before); + }); + + test("returns the same answer for the same document, twice", () => { + expect(JSON.stringify(inspect(flat))).toBe(JSON.stringify(inspect(flat))); + }); +}); diff --git a/packages/tx-manifest/src/document/inspect.ts b/packages/tx-manifest/src/document/inspect.ts new file mode 100644 index 0000000..8cc05e6 --- /dev/null +++ b/packages/tx-manifest/src/document/inspect.ts @@ -0,0 +1,110 @@ +import { isRecord } from "./json"; +import { normaliseManifest, type NormalisationNote } from "./normalise"; +import { + DOCUMENT_ONLY_REJECTS, + NEEDS_MORE_THAN_THE_DOCUMENT_REJECTS, + type Refusal, + type RejectToken, + refuseFromDocumentAlone, +} from "./refuse"; +import { type ConstructReport, describeConstructs } from "./registry"; + +/** + * What this package makes of one document, for a reader who is not a wallet. + * + * Everything here is already computed on the wallet's own path and thrown away afterwards, + * because a wallet needs a plan or a refusal and not an account of how it got there. A + * developer holding a protocol document needs exactly that account and has no way to obtain + * it short of connecting a wallet and trying, so this is the same work with the intermediate + * results kept. + */ +export type ManifestInspection = { + /** Every construct the document declares, against what this runtime does with it. */ + constructs: ConstructReport[]; + /** + * The first refusal decided from the document alone, if any. + * + * Its absence is not a statement that a wallet would build this action. Read it with + * `skipped` and `unreachable`, which say what was not asked. + */ + refusal: Refusal | undefined; + /** Older spellings this runtime rewrote, with where each was found. */ + rewrites: NormalisationNote[]; + /** + * Checks the caller's inputs did not allow, out of those a document alone can decide. + * + * Empty when the caller supplied a compiler version and a policy asset. + */ + skipped: RejectToken[]; + /** Refusals no reading of the document can reach; they need money, a chain or a request. */ + unreachable: RejectToken[]; +}; + +/** A document that could not be read far enough to inspect. */ +export type ManifestFault = { + /** What was wrong with it, in a sentence for a person. */ + reason: string; +}; + +export type InspectManifestResult = + | ({ ok: true } & ManifestInspection) + | ({ ok: false } & ManifestFault); + +export type InspectManifestOptions = { + /** + * The single SimplicityHL version the reading wallet ships. + * + * Omitted, the compiler check is skipped and reported as skipped. There is no sensible + * stand-in: any version supplied here decides whether a document is refused, so guessing + * one manufactures a verdict. + */ + compilerVersion?: string; + /** Contract sources by path, for the `simc` range a source may open with. */ + contractSources?: Record<string, string>; + /** The network's own asset, which is the only one this runtime moves. */ + policyAsset?: string; +}; + +/** + * Reads one document and reports what this package makes of it, without building anything. + * + * Nothing here reaches a network, holds a key or remembers anything, which is what makes it + * safe to run against a document of unknown origin — including in a browser, which is the + * caller this exists for. + */ +export function inspectManifestDocument( + document: unknown, + options: InspectManifestOptions = {}, +): InspectManifestResult { + if (!isRecord(document)) { + return { + ok: false, + reason: Array.isArray(document) + ? "A manifest is a JSON object, and this is a JSON array." + : `A manifest is a JSON object, and this is ${describe(document)}.`, + }; + } + + const { manifest, notes } = normaliseManifest(document); + const { refusal, skipped } = refuseFromDocumentAlone(manifest, options); + + return { + constructs: describeConstructs(manifest), + ok: true, + refusal, + rewrites: notes, + skipped, + unreachable: [...NEEDS_MORE_THAN_THE_DOCUMENT_REJECTS], + }; +} + +/** Every refusal a document alone can decide, whether or not this call could ask for it. */ +export const DOCUMENT_ONLY_REFUSALS: readonly RejectToken[] = DOCUMENT_ONLY_REJECTS; + +function describe(value: unknown): string { + if (value === null) { + return "null"; + } + + return `a ${typeof value}`; +} diff --git a/packages/tx-manifest/src/document/refuse.ts b/packages/tx-manifest/src/document/refuse.ts index 0b46732..ed76f37 100644 --- a/packages/tx-manifest/src/document/refuse.ts +++ b/packages/tx-manifest/src/document/refuse.ts @@ -85,6 +85,93 @@ export function refuseUnsupported( ); } +/** + * The refusals a document can be checked for on its own, and the ones it cannot. + * + * The split is a fact about which inputs each check needs, not a judgement about which + * matter. Everything in the second list is decided against money, a chain, a fee rate or a + * filled request, so a reader who has only the document has not been told those are fine — + * they have been told nothing about them, and any surface reporting the first list has to + * say so or it reads as a verdict it did not reach. + */ +export const DOCUMENT_ONLY_REJECTS = [ + "foreign-chain", + "unimplemented-construct", + "unrecognised-construct", + "foreign-compiler", + "unreadable-build-mode", + "unproducible-witness", + "foreign-asset", + "unbuildable-utxo-type", +] as const satisfies readonly RejectToken[]; + +export const NEEDS_MORE_THAN_THE_DOCUMENT_REJECTS = [ + "incomplete-request", + "no-such-action", + "no-utxo-to-spend", + "chain-read-failed", + "covenant-mismatch", + "no-fee-rate", + "no-funds-at-signing-address", + "shortfall", + "document-fault", +] as const satisfies readonly RejectToken[]; + +/** + * The same refusals as {@link refuseUnsupported}, for a reader who is not a wallet. + * + * Four of the eight need nothing but the document. The compiler check needs the version a + * wallet ships and the two asset checks need the network's own asset, and a caller holding + * neither gets those checks skipped rather than answered — passing a stand-in would turn + * "not checked" into "checked and fine", which is the one thing this must not do. + * + * Deliberately not a parameter of `refuseUnsupported`: a wallet always holds both, and an + * optional field on the wallet's own path is an invitation to omit one there. + */ +export function refuseFromDocumentAlone( + manifest: NormalisedManifest, + input: { + compilerVersion?: string; + contractSources?: Record<string, string>; + policyAsset?: string; + }, +): { refusal: Refusal | undefined; skipped: RejectToken[] } { + const skipped: RejectToken[] = []; + + const compiler = + input.compilerVersion === undefined + ? undefined + : refuseForeignCompiler(manifest, { + compilerVersion: input.compilerVersion, + contractSources: input.contractSources ?? {}, + }); + + if (input.compilerVersion === undefined) { + skipped.push("foreign-compiler"); + } + + const asset = + input.policyAsset === undefined + ? undefined + : (refuseForeignAsset(manifest, input.policyAsset) ?? + refuseUnbuildableUtxoType(manifest, input.policyAsset)); + + if (input.policyAsset === undefined) { + skipped.push("foreign-asset", "unbuildable-utxo-type"); + } + + return { + refusal: + refuseForeignChain(manifest) ?? + refuseUnrecognisedConstruct(manifest) ?? + compiler ?? + refuseUnreadableBuildMode(manifest) ?? + refuseUnproducibleWitness(manifest) ?? + asset, + skipped, + }; +} + /** * Whether this protocol's contracts are built with debug symbols. * diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index 414cb8f..052ee5c 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -30,6 +30,71 @@ export function loadBearing(findings: ConstructFinding[]): ConstructFinding[] { return findings.filter((finding) => finding.loadBearing); } +/** + * What the runtime does with one construct, as a name rather than as two flags. + * + * The flags are the right shape for deciding — `loadBearing` is the whole of what a refusal + * turns on — and the wrong shape for showing, because the four combinations are four + * different sentences and none of them is "true" or "false". `unrecognised` is the fifth + * outcome and not one of the four: it is the state of a key no site lists, which the flags + * carry as `declared: false` rather than as a combination of their own. + */ +export type ConstructState = + /** Read, and what it says changes what gets signed. */ + | "acted-on" + /** Read, and shown to a person; it decides nothing. */ + | "shown" + /** The format defines it and this runtime does not implement it. */ + | "unimplemented" + /** Known, and deliberately read by nothing, here or in the reference implementation. */ + | "never-read" + /** No site lists it, so no specification this runtime knows describes it here. */ + | "unrecognised"; + +/** One construct the document declares, and what this runtime makes of it. */ +export type ConstructReport = { + /** Where it was found, in the document's own terms. */ + at: string; + key: string; + state: ConstructState; +}; + +/** + * Every construct the document declares, each against what the runtime does with it. + * + * The companion to {@link inspectConstructs} rather than a replacement: that one answers + * "what must this refuse on", which is why it returns only what is unhandled, and this one + * answers "what is in this document", which needs the handled ones too. Both walk the same + * table, so neither can drift from the other. + */ +export function describeConstructs(manifest: NormalisedManifest): ConstructReport[] { + const reports: ConstructReport[] = []; + + walkSites(manifest, (node, kind, at) => { + const site: ConstructSite = SITES[kind]; + + for (const key of Object.keys(node)) { + reports.push({ at, key, state: stateOf(site, key) }); + } + }); + + return reports; +} + +function stateOf(site: ConstructSite, key: string): ConstructState { + const construct = site.constructs[key]; + + if (!construct) { + return "unrecognised"; + } + + if (construct.handled) { + return construct.loadBearing ? "acted-on" : "shown"; + } + + return construct.loadBearing ? "unimplemented" : "never-read"; +} + /** * How the runtime treats one construct at one kind of site. * @@ -251,26 +316,60 @@ type SiteKind = keyof typeof SITES; export function inspectConstructs(manifest: NormalisedManifest): ConstructFinding[] { const findings: ConstructFinding[] = []; - inspectSite(manifest.node, "manifest", "manifest", findings); + walkSites(manifest, (node, kind, at) => { + const site: ConstructSite = SITES[kind]; + + for (const key of Object.keys(node)) { + const construct = site.constructs[key]; + + if (construct?.handled) { + continue; + } + + findings.push({ + at, + declared: construct !== undefined, + key, + loadBearing: construct ? construct.loadBearing : site.unknownIsLoadBearing, + }); + } + }); + + return findings; +} + +/** One position in the document, and what it declares. */ +type SiteVisitor = (node: Record<string, unknown>, kind: SiteKind, at: string) => void; + +/** + * Every position in the document, in one place. + * + * Extracted so that refusing and reporting read the same traversal rather than two copies of + * it: a position added to one and forgotten in the other is a construct that refuses without + * appearing, or appears without refusing, and neither is discoverable by reading either + * function alone. + */ +function walkSites(manifest: NormalisedManifest, visit: SiteVisitor): void { + visitSite(manifest.node, "manifest", "manifest", visit); for (const action of manifest.actions) { const where = `action ${action.name}`; - inspectSite(action.node, "action", where, findings); - inspectSite(asRecord(action.node.ui), "ui", where, findings); + visitSite(action.node, "action", where, visit); + visitSite(asRecord(action.node.ui), "ui", where, visit); for (const [name, declared] of Object.entries(asRecord(action.node.params) ?? {})) { - inspectSite(asRecord(declared), "param", `${where} / param ${name}`, findings); + visitSite(asRecord(declared), "param", `${where} / param ${name}`, visit); } - inspectEntries(asArray(action.node.inputs), "input", where, findings); - inspectEntries(asArray(action.node.outputs), "output", where, findings); + visitEntries(asArray(action.node.inputs), "input", where, visit); + visitEntries(asArray(action.node.outputs), "output", where, visit); for (const declared of asArray(action.node.validations)) { const rule = asRecord(declared); const id = typeof rule?.id === "string" ? rule.id : "(unnamed)"; - inspectSite(rule, "validation", `${where} / validation ${id}`, findings); + visitSite(rule, "validation", `${where} / validation ${id}`, visit); } } @@ -278,58 +377,41 @@ export function inspectConstructs(manifest: NormalisedManifest): ConstructFindin const where = `utxo type ${name}`; const utxoType = asRecord(declared); - inspectSite(utxoType, "utxoType", where, findings); - inspectSite(asRecord(utxoType?.script), "script", `${where} / script`, findings); + visitSite(utxoType, "utxoType", where, visit); + visitSite(asRecord(utxoType?.script), "script", `${where} / script`, visit); } - - return findings; } /** Inputs and outputs both carry an id, a display block and per-entry witnesses. */ -function inspectEntries( +function visitEntries( entries: unknown[], kind: "input" | "output", where: string, - findings: ConstructFinding[], + visit: SiteVisitor, ): void { for (const declared of entries) { const entry = asRecord(declared); const id = typeof entry?.id === "string" ? entry.id : "(unnamed)"; const at = `${where} / ${kind} ${id}`; - inspectSite(entry, kind, at, findings); - inspectSite(asRecord(entry?.ui), "ui", at, findings); + visitSite(entry, kind, at, visit); + visitSite(asRecord(entry?.ui), "ui", at, visit); for (const [name, witness] of Object.entries(asRecord(entry?.witnesses) ?? {})) { - inspectSite(asRecord(witness), "witness", `${at} / witness ${name}`, findings); + visitSite(asRecord(witness), "witness", `${at} / witness ${name}`, visit); } } } -function inspectSite( +function visitSite( node: Record<string, unknown> | undefined, kind: SiteKind, at: string, - findings: ConstructFinding[], + visit: SiteVisitor, ): void { if (!node) { return; } - const site: ConstructSite = SITES[kind]; - - for (const key of Object.keys(node)) { - const construct = site.constructs[key]; - - if (construct?.handled) { - continue; - } - - findings.push({ - at, - declared: construct !== undefined, - key, - loadBearing: construct ? construct.loadBearing : site.unknownIsLoadBearing, - }); - } + visit(node, kind, at); } diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts index 5a5c797..ac1a9bf 100644 --- a/packages/tx-manifest/src/index.ts +++ b/packages/tx-manifest/src/index.ts @@ -39,3 +39,20 @@ export { spentInputs } from "./chain/spentInputs"; // 4. What a person is shown, and where each value on that screen came from. export { type ShownConfirmation, describeOrigin, toShownConfirmation } from "./confirmation"; export { type Provenanced, computed, fromSite, verified } from "./confirmation/provenance"; + +// 5. What this package makes of a document, for a reader who is not a wallet. +// The four steps above are one flow and this is not part of it: it builds nothing and is +// here so that a developer can find out what a document means to this runtime without +// connecting a wallet to ask. One function rather than the three readers behind it, so +// the answer stays a thing this package says rather than three internals a caller +// assembles into an answer of its own. +export { + type InspectManifestOptions, + type InspectManifestResult, + type ManifestFault, + type ManifestInspection, + DOCUMENT_ONLY_REFUSALS, + inspectManifestDocument, +} from "./document/inspect"; +export type { ConstructReport, ConstructState } from "./document/registry"; +export type { NormalisationNote } from "./document/normalise"; From b3da57b31c5c291907e806266e9b5841e5ac75a9 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 23:15:17 +0300 Subject: [PATCH 095/124] test(web): assert what the inspector draws, not only what it is handed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocker on this work was that nobody had seen the rendered page, and the reason given for leaving it there was that this repository has no DOM in its tests. That was true and it was not the whole picture: react-dom is already a dependency here, and renderToStaticMarkup needs no DOM at all. What the page draws is words, and a string carries words. Nineteen tests over the three panels and the view itself. The sharpest is the one this surface most needs: that an absent refusal renders as "no refusal that a document alone can decide" together with "not a statement that the wallet would build", and never as a bare empty space that reads like approval. Beside it, that the twelve unreached checks are named whether or not a refusal was found, that each of the five field states is explained rather than only labelled, that a document needing no rewriting says so rather than showing an empty region, and that the whole view renders with no wallet context in scope — which is what AC-06 turns on, since every other surface in this app reads one and a missing one throws. 573 tests across 40 files, zero lint warnings in the new code, web build clean. What is still unverified is layout and whether the wording actually helps someone who is not me: that needs eyes, and no amount of string matching substitutes. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- .../components/ConstructTable.test.tsx | 57 +++++++++++++ .../manifest/components/RefusalPanel.test.tsx | 79 +++++++++++++++++++ .../manifest/components/RewriteList.test.tsx | 49 ++++++++++++ apps/web/src/app/manifest/index.test.tsx | 38 +++++++++ 4 files changed, 223 insertions(+) create mode 100644 apps/web/src/app/manifest/components/ConstructTable.test.tsx create mode 100644 apps/web/src/app/manifest/components/RefusalPanel.test.tsx create mode 100644 apps/web/src/app/manifest/components/RewriteList.test.tsx create mode 100644 apps/web/src/app/manifest/index.test.tsx diff --git a/apps/web/src/app/manifest/components/ConstructTable.test.tsx b/apps/web/src/app/manifest/components/ConstructTable.test.tsx new file mode 100644 index 0000000..838e21f --- /dev/null +++ b/apps/web/src/app/manifest/components/ConstructTable.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ConstructTable } from "./ConstructTable"; + +// AC-03 at the surface. The five states and the positions come from the package and are +// tested there; what is checked here is that a reader is shown the state, the field, where it +// sits, and — the part a state name alone does not carry — what that state means for them. + +function report(state: ConstructState, key: string = state, at = "manifest"): ConstructReport { + return { at, key, state }; +} + +function render(constructs: ConstructReport[]): string { + return renderToStaticMarkup(<ConstructTable constructs={constructs} />); +} + +describe("what a reader is told about each field", () => { + test("shows the field, where it sits, and its state", () => { + const html = render([report("unimplemented", "args", "action Pay")]); + + expect(html).toContain("args"); + expect(html).toContain("action Pay"); + expect(html).toContain("unimplemented"); + }); + + test("explains what each state means rather than only naming it", () => { + expect(render([report("acted-on")])).toContain("changes what gets signed"); + expect(render([report("shown")])).toContain("It decides nothing"); + expect(render([report("unimplemented")])).toContain("does not implement it"); + expect(render([report("unrecognised")])).toContain("No specification this wallet knows"); + expect(render([report("never-read")])).toContain("read by nothing"); + }); + + test("a document declaring nothing says so rather than drawing an empty table", () => { + const html = render([]); + + expect(html).toContain("declares no fields"); + expect(html).not.toContain("<table"); + }); + + test("two fields at different positions are both shown, not collapsed by name", () => { + const html = render([ + report("acted-on", "description", "action Pay"), + report("acted-on", "description", "action Receive"), + ]); + + expect(html).toContain("action Pay"); + expect(html).toContain("action Receive"); + }); + + test("draws no heading for a state this document does not use", () => { + expect(render([report("acted-on")])).not.toContain("never-read"); + }); +}); diff --git a/apps/web/src/app/manifest/components/RefusalPanel.test.tsx b/apps/web/src/app/manifest/components/RefusalPanel.test.tsx new file mode 100644 index 0000000..19d6097 --- /dev/null +++ b/apps/web/src/app/manifest/components/RefusalPanel.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { RefusalPanel } from "./RefusalPanel"; + +// AC-04 and AC-05, at the only place they can be checked: the text a reader actually meets. +// Rendered to a string rather than to a DOM, because this repository has no DOM in its tests +// and react-dom is already here — the assertions below are about words on a screen, and a +// string carries those. + +function render(inspection: Parameters<typeof RefusalPanel>[0]["inspection"]): string { + return renderToStaticMarkup(<RefusalPanel inspection={inspection} />); +} + +const NOTHING_ASKED: Pick< + Parameters<typeof RefusalPanel>[0]["inspection"], + "skipped" | "unreachable" +> = { + skipped: [], + unreachable: ["covenant-mismatch", "shortfall", "no-fee-rate"], +}; + +describe("what a reader is told about refusal", () => { + test("prints the refusal's stable token and its sentence", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { reason: 'This protocol is for "bitcoin".', reject: "foreign-chain" }, + }); + + expect(html).toContain("foreign-chain"); + expect(html).toContain("bitcoin"); + }); + + // The single most misreadable thing on the page. A document can be flawless in every way a + // document can be judged and still be unbuildable for want of money. + test("never lets no-refusal read as a promise that the wallet would build", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("No refusal that a document alone can decide"); + expect(html).toContain("not a statement that the wallet would build"); + }); + + test("names the unreachable checks whether or not a refusal was found", () => { + for (const refusal of [undefined, { reason: "…", reject: "foreign-chain" as const }]) { + const html = render({ ...NOTHING_ASKED, refusal }); + + expect(html).toContain("covenant-mismatch"); + expect(html).toContain("shortfall"); + expect(html).toContain("no-fee-rate"); + expect(html).toContain("Not checkable from a document at all"); + } + }); + + test("says why the unreachable ones are unreachable, not merely that they are", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("money"); + expect(html).toContain("chain read"); + }); + + test("separates checks it could have made from checks nothing could", () => { + const html = render({ + refusal: undefined, + skipped: ["foreign-compiler"], + unreachable: ["shortfall"], + }); + + expect(html).toContain("Not checked, because this page was not told what it needs"); + expect(html).toContain("foreign-compiler"); + expect(html).toContain("Not checkable from a document at all"); + }); + + test("says nothing about skipped checks when none were skipped", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).not.toContain("Not checked, because"); + }); +}); diff --git a/apps/web/src/app/manifest/components/RewriteList.test.tsx b/apps/web/src/app/manifest/components/RewriteList.test.tsx new file mode 100644 index 0000000..c915fce --- /dev/null +++ b/apps/web/src/app/manifest/components/RewriteList.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; + +import type { NormalisationNote } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { RewriteList } from "./RewriteList"; + +// AC-02 at the surface. The criterion asks for three things per rewrite — where, the name it +// now carries, the name it had — and for a clean document to say so rather than show nothing, +// because an empty region and "nothing needed rewriting" look identical and mean different +// things. + +function render(rewrites: NormalisationNote[]): string { + return renderToStaticMarkup(<RewriteList rewrites={rewrites} />); +} + +describe("what a reader is told about older spellings", () => { + test("shows the name found, the name it now carries, and where", () => { + const html = render([{ at: "action Pay", canonical: "is_constructor", found: "deploy" }]); + + expect(html).toContain("deploy"); + expect(html).toContain("is_constructor"); + expect(html).toContain("action Pay"); + }); + + test("a clean document says nothing was rewritten rather than showing an empty region", () => { + const html = render([]); + + expect(html).toContain("Nothing was rewritten"); + expect(html).toContain("current spelling"); + expect(html).not.toContain("<table"); + }); + + test("says what a rewrite means: the document is from an earlier generation", () => { + const html = render([{ at: "manifest", canonical: "params", found: "compile_params" }]); + + expect(html).toContain("earlier generation"); + }); + + test("shows every rewrite, not only the first", () => { + const html = render([ + { at: "manifest", canonical: "manifest_version", found: "compose_version" }, + { at: "manifest", canonical: "params", found: "compile_params" }, + ]); + + expect(html).toContain("compose_version"); + expect(html).toContain("compile_params"); + }); +}); diff --git a/apps/web/src/app/manifest/index.test.tsx b/apps/web/src/app/manifest/index.test.tsx new file mode 100644 index 0000000..7101c32 --- /dev/null +++ b/apps/web/src/app/manifest/index.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import ManifestInspector from "./index"; + +// AC-06. The claim is that this opens with no wallet installed, no connection and no network, +// and the strongest available check of it is that rendering the whole view touches no wallet +// context at all: every other surface in this app reads one, and reading a missing one here +// would throw rather than degrade. + +describe("the inspector with nothing around it", () => { + test("renders with no wallet context, no provider and no network", () => { + const html = renderToStaticMarkup(<ManifestInspector />); + + expect(html).toContain("Manifest inspector"); + expect(html).toContain("<textarea"); + }); + + test("says what it is and that nothing leaves the page", () => { + const html = renderToStaticMarkup(<ManifestInspector />); + + expect(html).toContain("Nothing is sent anywhere"); + expect(html).toContain("no wallet is needed"); + }); + + test("shows no result panels until something is pasted", () => { + const html = renderToStaticMarkup(<ManifestInspector />); + + expect(html).not.toContain("What the wallet would refuse"); + expect(html).not.toContain("What each field is"); + expect(html).not.toContain("What was rewritten"); + }); + + test("offers a document to start from, so the empty box is not the only way in", () => { + expect(renderToStaticMarkup(<ManifestInspector />)).toContain("Load the p2pk example"); + }); +}); From 48082dfc680f5206b1e48a32dd433d76d90dde37 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 7 Aug 2026 23:43:47 +0300 Subject: [PATCH 096/124] fix(web): say how many fields would refuse, not only which one is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by using this page on the five published protocols rather than on fixtures. Each of them refused on $schema — a decorative path to a schema file — and each therefore read as hopelessly unsupported, when the field table below said three fixable gaps. The maintainer read it that way, which is the only evidence that matters about how a surface reads. The runtime returns one refusal and does so deliberately: someone deciding whether to trust a site is not helped by eleven field names. That choice is not in question here. What is in question is letting one stand in for all of them on a page whose reader is diagnosing coverage rather than deciding trust — so the panel now says how many fields are in that class, and that fixing the named one uncovers the rest rather than finishing. Two tests: that the count appears with the remainder, and that it stays silent when the wallet's one refusal is the whole of it. 575 tests across 40 files, zero lint warnings in the new code, web build clean. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- .../manifest/components/RefusalPanel.test.tsx | 32 ++++++++++++++++++- .../app/manifest/components/RefusalPanel.tsx | 21 +++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/manifest/components/RefusalPanel.test.tsx b/apps/web/src/app/manifest/components/RefusalPanel.test.tsx index 19d6097..47ce01f 100644 --- a/apps/web/src/app/manifest/components/RefusalPanel.test.tsx +++ b/apps/web/src/app/manifest/components/RefusalPanel.test.tsx @@ -15,8 +15,9 @@ function render(inspection: Parameters<typeof RefusalPanel>[0]["inspection"]): s const NOTHING_ASKED: Pick< Parameters<typeof RefusalPanel>[0]["inspection"], - "skipped" | "unreachable" + "constructs" | "skipped" | "unreachable" > = { + constructs: [], skipped: [], unreachable: ["covenant-mismatch", "shortfall", "no-fee-rate"], }; @@ -61,6 +62,7 @@ describe("what a reader is told about refusal", () => { test("separates checks it could have made from checks nothing could", () => { const html = render({ + constructs: [], refusal: undefined, skipped: ["foreign-compiler"], unreachable: ["shortfall"], @@ -71,6 +73,34 @@ describe("what a reader is told about refusal", () => { expect(html).toContain("Not checkable from a document at all"); }); + // Found by using this page on the five published protocols: each refused on one decorative + // field and read as hopeless, when the field table below said three fixable gaps. + test("says how many fields would refuse, not only which one the wallet names", () => { + const html = render({ + ...NOTHING_ASKED, + constructs: [ + { at: "manifest", key: "$schema", state: "unrecognised" }, + { at: "manifest", key: "contract_templates", state: "unrecognised" }, + { at: "manifest", key: "simplicity_hl", state: "unrecognised" }, + { at: "manifest", key: "description", state: "shown" }, + ], + refusal: { reason: "…", reject: "unrecognised-construct" }, + }); + + expect(html).toContain("3 fields in this document would refuse"); + expect(html).toContain("The other 2"); + }); + + test("does not count when the wallet's one refusal is the whole of it", () => { + const html = render({ + ...NOTHING_ASKED, + constructs: [{ at: "manifest", key: "$schema", state: "unrecognised" }], + refusal: { reason: "…", reject: "unrecognised-construct" }, + }); + + expect(html).not.toContain("would refuse, and the wallet names"); + }); + test("says nothing about skipped checks when none were skipped", () => { const html = render({ ...NOTHING_ASKED, refusal: undefined }); diff --git a/apps/web/src/app/manifest/components/RefusalPanel.tsx b/apps/web/src/app/manifest/components/RefusalPanel.tsx index 3c0c653..2297954 100644 --- a/apps/web/src/app/manifest/components/RefusalPanel.tsx +++ b/apps/web/src/app/manifest/components/RefusalPanel.tsx @@ -10,12 +10,23 @@ import { Badge } from "@/components/ui/badge"; * money, a fee rate, or the covenant actually being where the state file says. So the * unreached checks are not a footnote: they are rendered in the same panel, at the same * weight, whether or not a refusal was found. + * + * The second most misreadable thing was found by using this page on real documents. The + * runtime returns one refusal and does so deliberately: a person deciding whether to trust a + * site is not helped by a list of eleven field names. But a developer diagnosing coverage is + * misled by it — five published protocols each refused on one decorative field, and each read + * as hopeless when the truth was three fixable gaps. Saying how many fields are in that class + * is not disagreeing with the runtime's choice; it is this page declining to let one stand in + * for all of them. */ export function RefusalPanel({ inspection, }: { - inspection: Pick<ManifestInspection, "refusal" | "skipped" | "unreachable">; + inspection: Pick<ManifestInspection, "constructs" | "refusal" | "skipped" | "unreachable">; }) { + const wouldRefuse = inspection.constructs.filter( + (report) => report.state === "unimplemented" || report.state === "unrecognised", + ); return ( <div className="flex flex-col gap-4"> {(() => { @@ -34,6 +45,14 @@ export function RefusalPanel({ {inspection.refusal.reject} </Badge> <p className="text-sm">{inspection.refusal.reason}</p> + {wouldRefuse.length > 1 && ( + <p className="text-muted-foreground text-xs"> + {wouldRefuse.length} fields in this document would refuse, and the wallet names the + first. The other {wouldRefuse.length - 1} are in the field table below, under + unrecognised and unimplemented — fixing this one uncovers them rather than + finishing. + </p> + )} </div> ); })()} From 28a0b41e0aab010b980794dd2aa0052eed125e39 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Sat, 8 Aug 2026 00:12:55 +0300 Subject: [PATCH 097/124] feat(tx-manifest): read the protocols as their authors write them now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet could not perform a single protocol its authors publish, and the reason was not a missing capability. The container holding a contract's actions had been renamed — classes.methods became contract_templates.actions — and the wallet knew the previous name. It reads what that container holds perfectly: given its own frozen copies of the same four protocols, which carry the old name, it finds every action in them, and has done throughout. Four names, settled in the layer that exists for exactly this: one place rewrites every known spelling into a single vocabulary, because the format changes faster than its specification and no field says which generation a document belongs to. This is the eighth such rename rather than new machinery. The container is read under both names and neither is preferred. A wallet that traded one for the other would be as blind to the previous generation as it was to this one, and the corpus keeps three generations of the lending protocol side by side on purpose. The build mode moved into a block of its own, and that is not cosmetic: it changes a contract's commitment root and therefore its address, so a document whose statement went unread was built the other way and refused against where the money actually sits. Two keys stopped refusing. A comment and a pointer to a schema file belong to JSON documents rather than to this format, appear at any depth and decide nothing — and one of them was refusing every published protocol, being the first unlisted key reached. They are answered once for every position rather than listed at each. The sentence written for whoever approves an action is decorative and not shown. Its text interpolates values through a syntax no specification describes, and a confident sentence about the wrong amounts changes what a person agrees to. What this moves, measured on the five documents their authors publish today: lending_v3 60 fields seen -> 620 refused on $schema -> on create_instance lending_v2 79 -> 645 last_will 14 -> 117 dex 22 -> 235 zeroconf 7 -> 7 refused on $schema -> refused nothing Zero fields remain that no position describes. Every refusal left names a feature this wallet has never built, and a new check records which, per protocol, in both generations — so the next time the format moves, a number moves with it instead of a person noticing. 583 tests across 41 files, zero lint warnings in the new code. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- .../src/__fixtures__/current/README.md | 17 + .../__fixtures__/current/dex.manifest.json | 431 +++++ .../current/last_will.manifest.json | 242 +++ .../current/lending_v2.manifest.json | 1349 +++++++++++++++ .../current/lending_v3.manifest.json | 1456 +++++++++++++++++ .../current/zeroconf.manifest.json | 9 + packages/tx-manifest/src/currency.test.ts | 158 ++ .../tx-manifest/src/document/normalise.ts | 69 +- packages/tx-manifest/src/document/registry.ts | 37 +- 9 files changed, 3755 insertions(+), 13 deletions(-) create mode 100644 packages/tx-manifest/src/__fixtures__/current/README.md create mode 100644 packages/tx-manifest/src/__fixtures__/current/dex.manifest.json create mode 100644 packages/tx-manifest/src/__fixtures__/current/last_will.manifest.json create mode 100644 packages/tx-manifest/src/__fixtures__/current/lending_v2.manifest.json create mode 100644 packages/tx-manifest/src/__fixtures__/current/lending_v3.manifest.json create mode 100644 packages/tx-manifest/src/__fixtures__/current/zeroconf.manifest.json create mode 100644 packages/tx-manifest/src/currency.test.ts diff --git a/packages/tx-manifest/src/__fixtures__/current/README.md b/packages/tx-manifest/src/__fixtures__/current/README.md new file mode 100644 index 0000000..3f6662a --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/current/README.md @@ -0,0 +1,17 @@ +# The same protocols, as their authors publish them now + +The five example manifests from `stringhandler/txmanifest-wallet` at +`1cbb5101833f35156f3581a9666a4b12236cd5d2`, fetched 2026-08-07 and vendored byte +for byte. + +They are the same protocols as four of the documents beside them, and they are +here **as well as** rather than instead of. The container that holds a contract's +actions was renamed between the two — `classes.methods` became +`contract_templates.actions` — and the wallet was blind to every one of these +documents until it was told the new name. A check that only ever ran against the +newer generation would hide the next rename exactly as the frozen copies hid this +one, and a check that only ran against the older one already did. + +`zeroconf` carries no actions in either generation. It is here because a protocol +that declares nothing is the smallest real document there is, and because it is +the first of these five the wallet refuses nothing about. diff --git a/packages/tx-manifest/src/__fixtures__/current/dex.manifest.json b/packages/tx-manifest/src/__fixtures__/current/dex.manifest.json new file mode 100644 index 0000000..6fd8de1 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/current/dex.manifest.json @@ -0,0 +1,431 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "$comment": "Ported from Mosaik's tessera.simf (github.com/kaleidoswap/mosaik, crates/tessera/contracts/tessera.simf). Upstream substitutes the four offer terms as inline TESSERA_PARAM literals; here they are ordinary compile params. Change tessera.simf and every offer address changes — the terms live in the tapleaf. NOTE: the Refund method is not executable until upnext/12 (absolute nLockTime) lands; see its description.", + "manifest_version": "0.1.0", + "protocol": "tessera-dex", + "description": "Tessera — a keyless atomic swap offer on Liquid, the primitive a Mosaik DEX is built from. One UTXO is one all-or-nothing offer: it holds asset A and is spendable by ANYONE who pays AMOUNT_B of ASSET_B to the maker (Settle), or, after TIMEOUT, by anyone who returns asset A to the maker (Refund). No signature on either path — the covenant is pure transaction introspection, and the maker is identified only by a scriptPubKey hash. Modelled as a class: one instance per offer.", + "chain": "liquid", + "utxo_types": { + "tessera_offer": { + "description": "The offer UTXO: holds OFFER_AMOUNT of the maker's asset A, spendable via the keyless Settle or Refund paths. Its address commits to the four offer terms (ASSET_B, AMOUNT_B, MAKER_SPK, TIMEOUT) plus MAX_FEE — so the terms cannot change once funded. It does NOT commit to asset A or its amount: the covenant never inspects them on the settle path (see the note in tessera.simf), which is why OFFER_ASSET_ID/OFFER_AMOUNT are instance fields but not compile params.", + "script": { + "type": "simplicity", + "source": "./tessera.simf", + "compile_params": { + "ASSET_B": "ASSET_B", + "AMOUNT_B": "AMOUNT_B", + "MAKER_SPK": "MAKER_SPK", + "TIMEOUT": "TIMEOUT", + "MAX_FEE": "MAX_FEE" + } + }, + "asset": "OFFER_ASSET_ID", + "confidential": false + }, + "maker_payout": { + "description": "The maker's payout address — a p2pk covenant over MAKER_PUB_KEY (see maker_payout.simf for why the payout leg is a covenant and not a plain wallet address). MAKER_SPK is the sha256 of this address's scriptPubKey. Asset-agnostic: it holds ASSET_B after a Settle, or asset A after a Refund, since the program only checks a signature. The 'asset' below is the Settle case, and the Refund output overrides it.", + "script": { + "type": "simplicity", + "source": "./maker_payout.simf", + "compile_params": { + "PUB_KEY": "MAKER_PUB_KEY" + } + }, + "asset": "ASSET_B", + "confidential": false + } + }, + "contract_templates": { + "tessera_offer_contract": { + "description": "One Tessera swap offer. Created by MakeOffer, then consumed by exactly one of Settle (a taker fills it) or Refund (it expired). ClaimPayout is the maker's follow-up sweep of their proceeds.", + "fields": { + "OFFER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset A — what the maker is selling, locked in the offer UTXO." + }, + "OFFER_AMOUNT": { + "type": "u64", + "description": "How much of asset A is locked in the offer." + }, + "ASSET_B": { + "type": "liquid.asset_id", + "description": "Asset B — what the maker wants in return." + }, + "AMOUNT_B": { + "type": "u64", + "description": "Exactly how much of asset B the maker must be paid to settle." + }, + "MAKER_PUB_KEY": { + "type": "pubkey", + "description": "The maker's x-only key. Not used by the swap covenant — it defines the payout address, and later authorises ClaimPayout." + }, + "MAKER_SPK": { + "type": "bytes32", + "description": "sha256 of the maker payout scriptPubKey. This, not a key, is what the covenant enforces payment to. Computed by MakeOffer." + }, + "TIMEOUT": { + "type": "u32", + "description": "Absolute block height at which the offer becomes refundable." + }, + "MAX_FEE": { + "type": "u64", + "description": "Most a refund sweep may skim from the returned asset to pay the network fee." + } + }, + "actions": { + "MakeOffer": { + "description": "Publish an offer: lock asset A into the Tessera covenant and write the instance file recording the terms. From here the offer is live and anyone may fill it — no further action from the maker.", + "intent": "publish a swap offer: {params.OFFER_AMOUNT} of {params.OFFER_ASSET_ID:symbol} for {params.AMOUNT_B} of {params.ASSET_B:symbol}", + "params": { + "OFFER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset A — the asset id you are selling." + }, + "OFFER_AMOUNT": { + "type": "u64", + "description": "How much of asset A to lock in the offer." + }, + "ASSET_B": { + "type": "liquid.asset_id", + "description": "Asset B — the asset id you want to be paid in." + }, + "AMOUNT_B": { + "type": "u64", + "description": "Exactly how much of asset B you must receive. A taker pays this or the covenant rejects the spend — there are no partial fills." + }, + "MAKER_PUB_KEY": { + "type": "pubkey", + "description": "Your x-only public key — it defines where takers must pay you. Must be one of your own wallet's keys so you can later sweep the proceeds with ClaimPayout." + }, + "TIMEOUT": { + "type": "u32", + "description": "Absolute block height after which the offer may be refunded back to you. Pick a height comfortably in the future — until it passes, the only way out of the offer is for someone to fill it." + }, + "MAX_FEE": { + "type": "u64", + "default": "5000", + "description": "Most a refund sweep may skim from your returned asset to pay the network fee. Since anyone may sweep, this bounds what a griefer can burn." + } + }, + "inputs": [ + { + "id": "offer_asset_in", + "description": "Wallet UTXO providing asset A, sized >= OFFER_AMOUNT.", + "utxo_source": "wallet", + "asset": "params.OFFER_ASSET_ID", + "amount_sat": { + "min_amount": "params.OFFER_AMOUNT" + }, + "ui": { + "label": "the asset you are selling", + "role": "offer_asset" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "offer_out", + "description": "The funded offer — a live Tessera covenant UTXO holding asset A.", + "destination": { + "utxo_type": "tessera_offer" + }, + "asset": "params.OFFER_ASSET_ID", + "amount_sat": "params.OFFER_AMOUNT", + "confidential": false, + "ui": { + "label": "asset locked into the swap offer", + "role": "covenant" + } + }, + { + "id": "offer_change", + "description": "Asset A change back to the maker's wallet.", + "destination": "change", + "asset": "params.OFFER_ASSET_ID", + "optional": true, + "ui": { + "label": "unsold remainder returned to you", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ], + "create_instance": { + "fields": { + "OFFER_ASSET_ID": "$params.OFFER_ASSET_ID", + "OFFER_AMOUNT": "$params.OFFER_AMOUNT", + "ASSET_B": "$params.ASSET_B", + "AMOUNT_B": "$params.AMOUNT_B", + "MAKER_PUB_KEY": "$params.MAKER_PUB_KEY", + "TIMEOUT": "$params.TIMEOUT", + "MAX_FEE": "$params.MAX_FEE", + "MAKER_SPK": { + "type": "tapleaf", + "simf": "./maker_payout.simf", + "params": { + "PUB_KEY": { + "type": "pubkey", + "value": "MAKER_PUB_KEY" + } + } + } + } + } + }, + "Settle": { + "description": "Fill the offer: pay the maker AMOUNT_B of asset B and take asset A. Permissionless — anyone holding asset B can run this, the covenant carries no signature and does not care who you are. It only checks that output 0 pays exactly AMOUNT_B of ASSET_B to MAKER_SPK.", + "intent": "fill the offer: pay {instance.AMOUNT_B} of {instance.ASSET_B:symbol} and take {instance.OFFER_AMOUNT} of {instance.OFFER_ASSET_ID:symbol}", + "inputs": [ + { + "id": "offer_in", + "description": "The Tessera offer UTXO being filled, located via the state file. amount_sat is declared because covenant inputs are never chain-fetched: the engine takes an input's amount from --inputs-file, then the state file, then this spec — and silently falls back to 0 if all three are absent. MakeOffer funds the offer with exactly OFFER_AMOUNT, so the instance always knows it.", + "utxo_source": { + "utxo_type": "tessera_offer" + }, + "asset": "instance.OFFER_ASSET_ID", + "amount_sat": "instance.OFFER_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<u32, u32>", + "value": "Left(0)", + "description": "Settle path (PATH::LEFT). The u32 payload is the index of the output paying the maker — output 0 below. Output index is set by declaration order, so maker_payout_out must stay first." + } + }, + "ui": { + "label": "the swap offer being filled", + "role": "covenant" + } + }, + { + "id": "payment_in", + "description": "Taker's wallet UTXO providing asset B, sized >= AMOUNT_B.", + "utxo_source": "wallet", + "asset": "instance.ASSET_B", + "amount_sat": { + "min_amount": "instance.AMOUNT_B" + }, + "ui": { + "label": "your payment to the maker", + "role": "payment" + } + }, + { + "id": "fee_input", + "description": "Taker's wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "maker_payout_out", + "description": "MUST be output 0 — the leg the covenant checks, and the index committed to by the PATH witness above. Exactly AMOUNT_B of asset B to the maker's payout address; anything else and the spend is rejected.", + "destination": { + "utxo_type": "maker_payout" + }, + "asset": "instance.ASSET_B", + "amount_sat": "instance.AMOUNT_B", + "required_index": 0, + "confidential": false, + "ui": { + "label": "payment to the maker", + "role": "maker_payout" + } + }, + { + "id": "taker_out", + "description": "Asset A released from the offer to the taker — the whole locked amount, since Tessera has no partial fills.", + "destination": "wallet", + "asset": "instance.OFFER_ASSET_ID", + "amount_sat": "offer_in.amount_sat", + "ui": { + "label": "asset you bought from the offer", + "role": "settlement" + } + }, + { + "id": "payment_change", + "description": "Asset B change back to the taker's wallet.", + "destination": "change", + "asset": "instance.ASSET_B", + "optional": true, + "ui": { + "label": "payment change returned to you", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "Refund": { + "description": "Sweep an expired offer back to the maker. Permissionless and keyless like Settle: after TIMEOUT anyone may run this, but the covenant forces the asset back to MAKER_SPK, so a stranger's only power is to pay the fee on the maker's behalf (or, at worst, burn MAX_FEE). NOT EXECUTABLE YET: the refund path calls jet::check_lock_height(TIMEOUT), an absolute CLTV, and the engine cannot set a transaction nLockTime — see meta/tasks/upnext/12-engine-absolute-locktime.md. Same limitation as the lending example's liquidation path. Modelled here so the covenant's second path is documented and ready once task 12 lands; the sequence below already enables locktime.", + "intent": "refund the expired offer: return {instance.OFFER_AMOUNT} of {instance.OFFER_ASSET_ID:symbol} to the maker", + "inputs": [ + { + "id": "offer_in", + "description": "The expired Tessera offer UTXO, located via the state file. The sequence below is ENABLE_LOCKTIME_NO_RBF (0xFFFFFFFE): anything other than 0xFFFFFFFF enables the transaction nLockTime that check_lock_height reads. The engine warns that this has the BIP68 disable bit set — that is expected and correct here, since Tessera's timeout is an absolute height, not a relative one.", + "utxo_source": { + "utxo_type": "tessera_offer" + }, + "asset": "instance.OFFER_ASSET_ID", + "amount_sat": "instance.OFFER_AMOUNT", + "sequence": 4294967294, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<u32, u32>", + "value": "Right(0)", + "description": "Refund path (PATH::RIGHT). The u32 payload is the index of the output returning the asset to the maker — output 0 below." + } + }, + "ui": { + "label": "the expired swap offer", + "role": "covenant" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee. Paying the fee from a separate input (rather than skimming the locked asset) means the refund output returns the full locked amount, which satisfies the covenant's `out + MAX_FEE >= in` check with nothing skimmed.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "maker_refund_out", + "description": "MUST be output 0 — the index committed to by the PATH witness. The full locked asset A back to the maker's payout address. Note the asset override: this is asset A, not the maker_payout type's default ASSET_B.", + "destination": { + "utxo_type": "maker_payout" + }, + "asset": "instance.OFFER_ASSET_ID", + "amount_sat": "offer_in.amount_sat", + "confidential": false, + "ui": { + "label": "unsold asset returned to the maker", + "role": "maker_payout" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "ClaimPayout": { + "description": "The maker sweeps their proceeds out of the payout covenant into their wallet. Run this after a taker settles the offer. Unlike every other method here, this one is a plain signature check — the swap is already over.", + "intent": "sweep your swap proceeds into your wallet", + "inputs": [ + { + "id": "payout_in", + "description": "The maker_payout covenant UTXO holding the proceeds. With --state (the normal flow, since Settle writes one) the amount and asset are read straight off the UTXO record and the values below are ignored entirely. They exist only as the fallback for pinning the outpoint with --input and no state file, and they describe the post-Settle claim: the payout holds exactly AMOUNT_B of ASSET_B. After a Refund it holds asset A instead — that claim needs --state, which is fine, since Refund is not executable yet anyway.", + "utxo_source": { + "utxo_type": "maker_payout" + }, + "asset": "instance.ASSET_B", + "amount_sat": "instance.AMOUNT_B", + "witnesses": { + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "instance.MAKER_PUB_KEY" + }, + "description": "BIP340 Schnorr signature over the whole transaction, from the maker's key." + } + }, + "ui": { + "label": "your swap proceeds", + "role": "maker_payout" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "claimed_out", + "description": "The swept proceeds, sent to the maker's wallet. Reads the amount off payout_in, so whatever actually landed in the payout address is what gets swept.", + "destination": "wallet", + "asset": "payout_in.asset", + "amount_sat": "payout_in.amount_sat", + "ui": { + "label": "proceeds swept to your wallet", + "role": "settlement" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/packages/tx-manifest/src/__fixtures__/current/last_will.manifest.json b/packages/tx-manifest/src/__fixtures__/current/last_will.manifest.json new file mode 100644 index 0000000..cda80f4 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/current/last_will.manifest.json @@ -0,0 +1,242 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "manifest_version": "0.1.0", + "protocol": "last-will", + "description": "Last Will — a recursive covenant with three spending paths: inherit (after a 180-day timelock), cold-key break-out, and hot-key refresh. Modelled as a class: one instance per will.", + "chain": "liquid", + "utxo_types": { + "last_will": { + "description": "Funds locked under the last-will covenant.", + "script": { + "type": "simplicity", + "source": "./last_will.simf", + "compile_params": { + "INHERITOR_PUB_KEY": "INHERITOR_PUB_KEY", + "HOT_PUB_KEY": "HOT_PUB_KEY", + "COLD_PUB_KEY": "COLD_PUB_KEY", + "INHERIT_BLOCKS": "INHERIT_BLOCKS" + } + }, + "asset": "lbtc", + "confidential": false + } + }, + "contract_templates": { + "last_will_contract": { + "description": "A last-will covenant. One instance per will: created by Fund, then spent via ColdBreak, Refresh, or Inherit.", + "fields": { + "INHERITOR_PUB_KEY": { + "type": "pubkey" + }, + "HOT_PUB_KEY": { + "type": "pubkey" + }, + "COLD_PUB_KEY": { + "type": "pubkey" + }, + "INHERIT_BLOCKS": { + "type": "u16" + } + }, + "actions": { + "Fund": { + "description": "Set up the will: lock funds into the covenant and write the instance file recording the three keys.", + "params": { + "INHERITOR_PUB_KEY": { + "type": "pubkey", + "description": "The heir's x-only public key. They can claim the funds 180 days after the last move." + }, + "HOT_PUB_KEY": { + "type": "pubkey", + "description": "Owner's hot key. Auto-filled from your wallet signing key." + }, + "COLD_PUB_KEY": { + "type": "pubkey", + "description": "Owner's cold key. Your wallet's oracle key — the covenant escape hatch." + }, + "INHERIT_BLOCKS": { + "type": "u16", + "default": "25920", + "description": "Blocks of inactivity before the heir may claim. ~180 days ≈ 25920 (1-minute Liquid blocks). Max 65535." + }, + "amount_sat": { + "type": "u64", + "description": "Amount in satoshis to place under the will." + } + }, + "inputs": [ + { + "id": "funding_input", + "description": "Wallet UTXO providing the funds.", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { + "min_amount": "params.amount_sat" + } + } + ], + "outputs": [ + { + "id": "will_out", + "description": "The funded last-will output.", + "destination": { + "utxo_type": "last_will" + }, + "amount_sat": "params.amount_sat", + "asset": "lbtc" + }, + { + "id": "change_out", + "description": "Change returned to the funding wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ], + "create_instance": { + "fields": { + "INHERITOR_PUB_KEY": "$params.INHERITOR_PUB_KEY", + "HOT_PUB_KEY": "$params.HOT_PUB_KEY", + "COLD_PUB_KEY": "$params.COLD_PUB_KEY", + "INHERIT_BLOCKS": "$params.INHERIT_BLOCKS" + } + } + }, + "ColdBreak": { + "description": "Owner breaks out of the covenant with the cold key. Funds return to the wallet and the covenant ends.", + "inputs": [ + { + "id": "will_in", + "description": "The last-will covenant UTXO, located via the state file.", + "utxo_source": { + "utxo_type": "last_will" + }, + "witnesses": { + "SPEND_PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<(), Either<(), ()>>", + "value": "Right(Left(()))", + "description": "Selects the cold-spend path." + }, + "COLD_SIG": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "compile_params.COLD_PUB_KEY" + }, + "description": "BIP340 signature from the cold key." + } + } + } + ], + "outputs": [ + { + "id": "to_wallet", + "description": "The reclaimed funds, sent to your wallet.", + "destination": "wallet", + "asset": "lbtc", + "amount_sat": "will_in.amount_sat - fee" + } + ] + }, + "Refresh": { + "description": "Owner moves the funds with the hot key, repeating the covenant. Output 0 must be the same last-will covenant; output 1 is the explicit fee.", + "inputs": [ + { + "id": "will_in", + "description": "The last-will covenant UTXO, located via the state file.", + "utxo_source": { + "utxo_type": "last_will" + }, + "witnesses": { + "SPEND_PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<(), Either<(), ()>>", + "value": "Right(Right(()))", + "description": "Selects the hot-key refresh path." + }, + "HOT_SIG": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "compile_params.HOT_PUB_KEY" + }, + "description": "BIP340 signature from the hot key." + } + } + } + ], + "outputs": [ + { + "id": "will_again", + "description": "The re-locked last-will output (same covenant address). Must be output 0.", + "destination": { + "utxo_type": "last_will" + }, + "asset": "lbtc", + "amount_sat": "will_in.amount_sat - fee", + "required_index": 0 + } + ] + }, + "Inherit": { + "description": "The heir claims the funds 180 days after the last move. Requires the inheritor's signature and a relative timelock.", + "inputs": [ + { + "id": "will_in", + "description": "The last-will covenant UTXO, located via the state file.", + "utxo_source": { + "utxo_type": "last_will" + }, + "sequence": { + "relative_blocks": "compile_params.INHERIT_BLOCKS" + }, + "witnesses": { + "SPEND_PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<(), Either<(), ()>>", + "value": "Left(())", + "description": "Selects the inherit path." + }, + "INHERITOR_SIG": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "compile_params.INHERITOR_PUB_KEY" + }, + "description": "BIP340 signature from the heir's key." + } + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO to pay the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true + } + ], + "outputs": [ + { + "id": "to_heir", + "description": "The inherited funds, sent to the heir's wallet.", + "destination": "wallet", + "asset": "lbtc", + "amount_sat": "will_in.amount_sat" + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/packages/tx-manifest/src/__fixtures__/current/lending_v2.manifest.json b/packages/tx-manifest/src/__fixtures__/current/lending_v2.manifest.json new file mode 100644 index 0000000..1ae0411 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/current/lending_v2.manifest.json @@ -0,0 +1,1349 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "manifest_version": "0.1.0", + "protocol": "simplicity-lending", + "simplicity_hl": { "debug_symbols": true }, + "description": "P2P collateralised lending on Liquid, wire-compatible with the simplicity-lending reference implementation (github BlockstreamResearch/simplicity-lending, `smplx-sdk` covenants). This 'v2' manifest reproduces that protocol's exact on-chain transaction layout so offers created with tx-manifest-wallet are discoverable and settleable by simplicity-lending's own CLI / indexer / web app, and vice-versa. Key differences from the standalone 'lending' example: (1) the principal payout AND the borrower NFT both go to the borrower's plain wallet address (an explicit v0 P2WPKH), committed into the covenant as sha256(scriptPubKey) — there is no p2pk covenant and no separate claim step; (2) the borrower NFT is held in the borrower's wallet during the active loan and spent with an ordinary signature on repayment; (3) NFT ordering is first-params, second-params, borrower, lender; (4) the creation OP_RETURN carries borrower_pubkey || principal_asset_id (64 bytes, internal asset order) for indexer discovery; (5) a single AMOUNTS_DECIMALS drives the bit-packed parameter NFTs, matching the reference wallet.", + "utxo_types": { + "pre_lock": { + "description": "Collateral held in the PreLockCovenant while the borrower's offer is open. PATH::LEFT (create_lending_path) lets a lender activate the loan; PATH::RIGHT (cancel_pre_lock_path) lets the borrower cancel with a Schnorr signature, burning all Utility NFTs. Address is byte-identical to simplicity-lending's pre_lock covenant for the same parameters.", + "script": { + "type": "simplicity", + "source": "./pre_lock.simf" + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "lending_collateral": { + "description": "Collateral locked in the active LendingCovenant. Released via repayment (borrower returns principal+interest, reclaims collateral) or liquidation (lender claims collateral after LOAN_EXPIRATION_TIME).", + "script": { + "type": "simplicity", + "source": "./lending.simf" + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "prelock_script_auth": { + "description": "ScriptAuth covenant wrapping the Utility NFTs during the offer phase. Verifies co-spending with the pre_lock UTXO (input 0).", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "compile_params": { + "SCRIPT_HASH": "PRE_LOCK_COV_HASH" + } + }, + "confidential": false + }, + "lending_script_auth": { + "description": "ScriptAuth covenant wrapping the two Parameter NFTs during the active loan phase. Verifies co-spending with the lending_collateral UTXO (input 0). Unlike the standalone example, the Borrower NFT is NOT wrapped here — it lives in the borrower's wallet.", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "compile_params": { + "SCRIPT_HASH": "LENDING_COV_HASH" + } + }, + "confidential": false + }, + "lender_principal_vault": { + "description": "AssetAuth covenant holding the principal+interest payment from the borrower. The lender withdraws by co-spending and burning the Lender NFT.", + "script": { + "type": "simplicity", + "source": "./asset_auth.simf", + "compile_params": { + "ASSET_ID": "LENDER_NFT_ASSET_ID", + "ASSET_AMOUNT": "1", + "WITH_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + } + }, + "actions": { + "Prepare": { + "description": "Utility method to split a wallet UTXO into 4 L-BTC UTXOs for the four issuances in IssueUtilityNFTs.", + "inputs": [ + { + "id": "input", + "description": "Wallet UTXO to split into 4 for IssueUtilityNFTs", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "split_utxo1", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo2", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo3", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo4", + "destination": "wallet", + "amount_sat": 1, + "asset": "lbtc" + }, + { + "id": "split_utxo5", + "destination": "change", + "asset": "lbtc" + } + ] + }, + "PrepareLender": { + "description": "Utility: ensure the lender wallet has a PRINCIPAL_AMOUNT-sat UTXO of PRINCIPAL_ASSET_ID ready for SetupLending. Accepts any UTXO of that asset and splits off the exact amount needed.", + "inputs": [ + { + "id": "principal_input", + "description": "Any wallet UTXO holding PRINCIPAL_ASSET_ID (at least PRINCIPAL_AMOUNT sat).", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT" + } + } + ], + "outputs": [ + { + "id": "principal_out", + "description": "Exact PRINCIPAL_AMOUNT sat ready for SetupLending principal_in.", + "destination": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false + } + ] + } + }, + "contract_templates": { + "lending_contract": { + "description": "P2P collateralised lending contract, wire-compatible with simplicity-lending. One instance per loan offer. Created by IssueUtilityNFTs; advanced through LockCollateral, SetupLending, RepayLoan / LiquidateAfterExpiry, and ClaimPrincipalWithInterest.", + "fields": { + "AMOUNTS_DECIMALS": { + "type": "u8" + }, + "BORROWER_ADDRESS": { + "type": "string" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "BORROWER_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "BORROWER_PUB_KEY": { + "type": "pubkey" + }, + "COLLATERAL_AMOUNT": { + "type": "u64" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id" + }, + "FIRST_PARAMETERS_ENCODED": { + "type": "u64" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "LENDER_PRINCIPAL_COV_HASH": { + "type": "bytes32" + }, + "LENDING_COV_HASH": { + "type": "bytes32" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32" + }, + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "PRE_LOCK_COV_HASH": { + "type": "bytes32" + }, + "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": { + "type": "bytes32" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id" + }, + "PRINCIPAL_INTEREST_AMOUNT": { + "type": "u64" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32" + }, + "SECOND_PARAMETERS_ENCODED": { + "type": "u64" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id" + } + }, + "actions": { + "IssueUtilityNFTs": { + "description": "Borrower issues four Liquid NFTs (First/Second Parameters NFTs, Borrower NFT, Lender NFT — in that output order). Computes all covenant hashes and the borrower payout commitment, then writes the instance file.", + "params": { + "BORROWER_PUB_KEY": { + "type": "pubkey", + "compute": { + "type": "wallet", "wallet": "key" + }, + "description": "Borrower's BIP340 Schnorr public key. Used for offer cancellation authorization and stored in the discovery OP_RETURN." + }, + "BORROWER_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "compute": { + "type": "wallet", "wallet": "script_hash" + }, + "description": "SHA-256 of the borrower's explicit (unblinded) index-0 P2WPKH scriptPubKey. Committed into the covenant as both PRINCIPAL_OUTPUT_SCRIPT_HASH and BORROWER_NFT_OUTPUT_SCRIPT_HASH — this is exactly what simplicity-lending bakes via hash_script(signer.get_address().script_pubkey())." + }, + "BORROWER_ADDRESS": { + "type": "string", + "compute": { + "type": "wallet", "wallet": "address" + }, + "description": "The borrower's explicit (unblinded) index-0 address string. The principal and Borrower NFT are paid here on activation; its scriptPubKey hashes to BORROWER_OUTPUT_SCRIPT_HASH. Fund collateral from this same address so a third-party lender can reconstruct the offer." + }, + "AMOUNTS_DECIMALS": { + "type": "u8", + "default": "0", + "description": "Single decimal mantissa applied to BOTH collateral and principal when bit-packing the Parameter NFTs. The bit-packed amount is `amount / 10^AMOUNTS_DECIMALS` and must round-trip exactly, so COLLATERAL_AMOUNT and PRINCIPAL_AMOUNT must each be exact multiples of 10^AMOUNTS_DECIMALS, and each `amount / 10^AMOUNTS_DECIMALS` must fit in 25 bits (< 33,554,432). Default 0 is lossless for any amount below 33,554,432 (use this unless your amounts are larger). Raise it (e.g. to 1, matching simplicity-lending's demo) only with amounts that are exact multiples of 10^AMOUNTS_DECIMALS — a non-divisible amount is silently truncated in the NFT, which makes the offer both unindexable and unspendable." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Liquid asset ID of the collateral (e.g., L-BTC)." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Exact collateral amount in asset base units (multiple of 10^AMOUNTS_DECIMALS)." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Liquid asset ID of the loan currency (e.g., a test USDT)." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Exact principal amount requested, in asset base units (multiple of 10^AMOUNTS_DECIMALS)." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "description": "Interest rate in basis points (10,000 = 100%). Max 65,535 basis points." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "Block height at or after which the lender may liquidate (CLTV). Max 2^27-1." + } + }, + "on_pre_broadcast": { + "set": { + "instance.FIRST_PARAMETERS_ENCODED": "params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.AMOUNTS_DECIMALS * 8796093022208 + params.AMOUNTS_DECIMALS * 140737488355328", + "instance.SECOND_PARAMETERS_ENCODED": "params.COLLATERAL_AMOUNT / pow(10, AMOUNTS_DECIMALS) + params.PRINCIPAL_AMOUNT / pow(10, AMOUNTS_DECIMALS) * 33554432" + } + }, + "create_instance": { + "fields": { + "AMOUNTS_DECIMALS": "$params.AMOUNTS_DECIMALS", + "BORROWER_ADDRESS": "$params.BORROWER_ADDRESS", + "BORROWER_NFT_ASSET_ID": "$instance.BORROWER_NFT_ASSET_ID", + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": "$params.BORROWER_OUTPUT_SCRIPT_HASH", + "BORROWER_OUTPUT_SCRIPT_HASH": "$params.BORROWER_OUTPUT_SCRIPT_HASH", + "BORROWER_PUB_KEY": "$params.BORROWER_PUB_KEY", + "COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "FIRST_PARAMETERS_ENCODED": "$instance.FIRST_PARAMETERS_ENCODED", + "FIRST_PARAMETERS_NFT_ASSET_ID": "$instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "$instance.LENDER_NFT_ASSET_ID", + "LENDER_PRINCIPAL_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth.simf", + "params": { + "ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "WITH_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "LENDING_COV_HASH": { + "type": "tapleaf", + "simf": "./lending.simf", + "params": { + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "FIRST_PARAMETERS_NFT_ASSET_ID" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "SECOND_PARAMETERS_NFT_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "LENDER_PRINCIPAL_COV_HASH": { + "type": "bytes32", + "value": "LENDER_PRINCIPAL_COV_HASH" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + } + } + }, + "LOAN_EXPIRATION_TIME": "$params.LOAN_EXPIRATION_TIME", + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "type": "tapleaf", + "simf": "./script_auth.simf", + "params": { + "SCRIPT_HASH": { + "type": "bytes32", + "value": "LENDING_COV_HASH" + } + } + }, + "PRE_LOCK_COV_HASH": { + "type": "tapleaf", + "simf": "./pre_lock.simf", + "params": { + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u16", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "FIRST_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "FIRST_PARAMETERS_NFT_ASSET_ID" + }, + "SECOND_PARAMETERS_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "SECOND_PARAMETERS_NFT_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "LENDING_COV_HASH": { + "type": "bytes32", + "value": "LENDING_COV_HASH" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + }, + "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" + }, + "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "BORROWER_NFT_OUTPUT_SCRIPT_HASH" + }, + "BORROWER_PUB_KEY": { + "type": "pubkey", + "value": "BORROWER_PUB_KEY" + } + } + }, + "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": { + "type": "tapleaf", + "simf": "./script_auth.simf", + "params": { + "SCRIPT_HASH": { + "type": "bytes32", + "value": "PRE_LOCK_COV_HASH" + } + } + }, + "PRINCIPAL_AMOUNT": "$params.PRINCIPAL_AMOUNT", + "PRINCIPAL_ASSET_ID": "$params.PRINCIPAL_ASSET_ID", + "PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000", + "PRINCIPAL_INTEREST_RATE": "$params.PRINCIPAL_INTEREST_RATE", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "$params.BORROWER_OUTPUT_SCRIPT_HASH", + "SECOND_PARAMETERS_ENCODED": "$instance.SECOND_PARAMETERS_ENCODED", + "SECOND_PARAMETERS_NFT_ASSET_ID": "$instance.SECOND_PARAMETERS_NFT_ASSET_ID" + } + }, + "inputs": [ + { + "id": "collateral_prefund_in", + "description": "Wallet UTXO holding at least COLLATERAL_AMOUNT of the collateral asset. Consumed to mint a dedicated, exact-amount collateral UTXO at BORROWER_ADDRESS (see collateral_at_borrower output) so LockCollateral can spend it from the committed address. Declared first so it claims a large-enough UTXO before the 1-sat issuance inputs.", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.COLLATERAL_AMOUNT" + } + }, + { + "id": "first_params_issuance_input", + "description": "Wallet UTXO whose outpoint determines FIRST_PARAMETERS_NFT_ASSET_ID (issued at output 0).", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "instance.FIRST_PARAMETERS_ENCODED", + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "second_params_issuance_input", + "description": "Wallet UTXO whose outpoint determines SECOND_PARAMETERS_NFT_ASSET_ID (issued at output 1).", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "instance.SECOND_PARAMETERS_ENCODED", + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.SECOND_PARAMETERS_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "borrower_nft_issuance_input", + "description": "Wallet UTXO whose outpoint determines BORROWER_NFT_ASSET_ID (issued at output 2).", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.BORROWER_NFT_ASSET_ID": "asset" + } + } + }, + { + "id": "lender_nft_issuance_input", + "description": "Wallet UTXO whose outpoint determines LENDER_NFT_ASSET_ID (issued at output 3).", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.LENDER_NFT_ASSET_ID": "asset" + } + } + } + ], + "outputs": [ + { + "id": "first_params_nft_out", + "description": "First Parameter NFT carrying bit-packed loan terms in the amount field (output 0).", + "destination": "wallet", + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.FIRST_PARAMETERS_ENCODED", + "confidential": false + }, + { + "id": "second_params_nft_out", + "description": "Second Parameter NFT carrying bit-packed base amounts in the amount field (output 1).", + "destination": "wallet", + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.SECOND_PARAMETERS_ENCODED", + "confidential": false + }, + { + "id": "borrower_nft_out", + "description": "Borrower auth NFT (output 2). Amount=1.", + "destination": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false + }, + { + "id": "lender_nft_out", + "description": "Lender auth NFT (output 3). Amount=1.", + "destination": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false + }, + { + "id": "collateral_at_borrower", + "description": "Dedicated collateral UTXO of exactly COLLATERAL_AMOUNT at the borrower's committed address (explicit). LockCollateral spends this (pinned via from_address), guaranteeing its input 0 is funded from the address whose hash the covenant commits to — which is what makes the offer indexable.", + "destination": "instance.BORROWER_ADDRESS", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false + }, + { + "id": "collateral_change", + "description": "Collateral-asset change back to borrower (surplus over COLLATERAL_AMOUNT).", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true + }, + { + "id": "fee_change", + "description": "L-BTC change back to borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "LockCollateral": { + "description": "Borrower locks collateral and the four Utility NFTs into their covenant addresses, creating the open offer on-chain. Output order — pre_lock collateral (0), first-params (1), second-params (2), borrower (3), lender (4), discovery OP_RETURN (5) — matches simplicity-lending's create_pre_lock so a third-party lender can activate it. Fund collateral from BORROWER_ADDRESS.", + "inputs": [ + { + "id": "collateral_in", + "description": "Borrower's collateral UTXO — pinned to BORROWER_ADDRESS so it is spent from the exact address whose hash the covenant commits to. This is the UTXO staged by IssueUtilityNFTs, and it is what the indexer reconstructs borrower_output_script_hash from (input 0's prevout).", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "from_address": "instance.BORROWER_ADDRESS", + "amount_sat": "instance.COLLATERAL_AMOUNT" + }, + { + "id": "first_params_in", + "description": "First Parameter NFT from wallet.", + "utxo_source": "wallet", + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT from wallet.", + "utxo_source": "wallet", + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT from wallet.", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "lender_nft_in", + "description": "Lender NFT from wallet.", + "utxo_source": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "pre_lock_out", + "description": "Collateral locked in the PreLockCovenant (output 0).", + "destination": { + "utxo_type": "pre_lock" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "required_index": 0 + }, + { + "id": "first_params_locked", + "description": "First Parameter NFT locked in prelock ScriptAuth (output 1).", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.FIRST_PARAMETERS_ENCODED", + "required_index": 1 + }, + { + "id": "second_params_locked", + "description": "Second Parameter NFT locked in prelock ScriptAuth (output 2).", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "instance.SECOND_PARAMETERS_ENCODED", + "required_index": 2 + }, + { + "id": "borrower_nft_locked", + "description": "Borrower NFT locked in prelock ScriptAuth (output 3).", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "lender_nft_locked", + "description": "Lender NFT locked in prelock ScriptAuth (output 4).", + "destination": { + "utxo_type": "prelock_script_auth" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 4 + }, + { + "id": "indexer_op_return", + "description": "Discovery OP_RETURN (output 5): borrower_pubkey (32) || principal_asset_id (32, internal order) = 64 bytes, exactly simplicity-lending's encode_creation_op_return_data.", + "destination": { + "type": "op_return" + }, + "data": "concat(instance.BORROWER_PUB_KEY, instance.PRINCIPAL_ASSET_ID)", + "required_index": 5 + }, + { + "id": "collateral_change", + "description": "Collateral change back to borrower.", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true + }, + { + "id": "fee_change", + "description": "L-BTC change back to borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "CancelOffer": { + "description": "Borrower cancels the open offer. Requires a BORROWER_PUB_KEY signature. Collateral returned; all Utility NFTs burned. Spends pre_lock via PATH::RIGHT.", + "inputs": [ + { + "id": "pre_lock_in", + "description": "Pre-lock collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "pre_lock" + }, + "required_index": 0, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Right(())", + "description": "Selects cancel_pre_lock_path (PATH::RIGHT)." + }, + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { + "type": "wallet", + "key": "instance.BORROWER_PUB_KEY" + }, + "description": "BIP340 Schnorr signature from BORROWER_PUB_KEY." + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in prelock ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 1, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in prelock ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 2, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT in prelock ScriptAuth (index 3).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 3, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT in prelock ScriptAuth (index 4).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 4, + "asset": "instance.LENDER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "collateral_returned", + "description": "Collateral returned to borrower (output 0).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "pre_lock_in.amount_sat", + "confidential": false, + "required_index": 0 + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 1 + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 2 + }, + { + "id": "borrower_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "lender_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 4 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "SetupLending": { + "description": "Lender activates the loan by spending the pre_lock covenant (PATH::LEFT). Collateral moves to LendingCovenant; principal is delivered to the borrower's wallet; the Borrower NFT is returned to the borrower's wallet; the Lender NFT goes to the lender. Layout matches simplicity-lending's create_lending_from_pre_lock.", + "inputs": [ + { + "id": "collateral_in", + "description": "Pre-lock collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "pre_lock" + }, + "required_index": 0, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Left(())", + "description": "Selects create_lending_path (PATH::LEFT)." + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in prelock ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 1, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in prelock ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 2, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT in prelock ScriptAuth (index 3).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 3, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT in prelock ScriptAuth (index 4).", + "utxo_source": { + "utxo_type": "prelock_script_auth" + }, + "required_index": 4, + "asset": "instance.LENDER_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "principal_in", + "description": "Lender's wallet UTXO providing the principal (index 5).", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT" + }, + "required_index": 5 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees (index 6).", + "utxo_source": "wallet", + "asset": "lbtc", + "required_index": 6 + } + ], + "outputs": [ + { + "id": "lending_collateral_out", + "description": "Collateral at LendingCovenant (output 0).", + "destination": { + "utxo_type": "lending_collateral" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "required_index": 0, + "confidential": false + }, + { + "id": "principal_to_borrower", + "description": "Principal delivered to the borrower's explicit wallet address (output 1). scriptPubKey hashes to PRINCIPAL_OUTPUT_SCRIPT_HASH.", + "destination": "instance.BORROWER_ADDRESS", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 1 + }, + { + "id": "first_params_relocked", + "description": "First Parameter NFT under lending ScriptAuth (output 2).", + "destination": { + "utxo_type": "lending_script_auth" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 2, + "confidential": false + }, + { + "id": "second_params_relocked", + "description": "Second Parameter NFT under lending ScriptAuth (output 3).", + "destination": { + "utxo_type": "lending_script_auth" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 3, + "confidential": false + }, + { + "id": "borrower_nft_to_borrower", + "description": "Borrower NFT returned to the borrower's explicit wallet address (output 4). scriptPubKey hashes to BORROWER_NFT_OUTPUT_SCRIPT_HASH.", + "destination": "instance.BORROWER_ADDRESS", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 4 + }, + { + "id": "lender_nft_to_lender", + "description": "Lender NFT to the lender's wallet (output 5).", + "destination": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 5 + }, + { + "id": "principal_change", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "required_index": -2 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true, + "required_index": -1 + } + ] + }, + "RepayLoan": { + "description": "Borrower repays principal+interest and reclaims collateral. Spends lending_collateral via PATH::LEFT. The Borrower NFT is spent from the borrower's wallet with an ordinary signature (index 3). Layout matches simplicity-lending's repay_loan.", + "inputs": [ + { + "id": "lending_in", + "description": "Active lending collateral UTXO (index 0).", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Left(())", + "description": "Selects loan_repayment_path (PATH::LEFT)." + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in lending ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "required_index": 1, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in lending ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "required_index": 2, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT held in the borrower's wallet (index 3). Spent with an ordinary wallet signature.", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "repayment_in", + "description": "Borrower's wallet UTXO providing principal+interest (index 4).", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT" + }, + "required_index": 4 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees (optional if principal is L-BTC).", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true + } + ], + "outputs": [ + { + "id": "collateral_returned", + "description": "Collateral returned to borrower (output 0).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 0 + }, + { + "id": "principal_interest_to_vault", + "description": "Principal+interest to the lender's AssetAuth vault (output 1). scriptPubKey hashes to LENDER_PRINCIPAL_COV_HASH.", + "destination": { + "utxo_type": "lender_principal_vault" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT", + "confidential": false, + "required_index": 1 + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 2 + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 3 + }, + { + "id": "borrower_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 4 + }, + { + "id": "repayment_change", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "LiquidateAfterExpiry": { + "description": "Lender claims collateral after loan expiry. Spends lending_collateral via PATH::RIGHT; the covenant enforces nLockTime >= LOAN_EXPIRATION_TIME (check_lock_height). Layout matches simplicity-lending's liquidate_loan.", + "inputs": [ + { + "id": "lending_in", + "description": "Active lending collateral UTXO (index 0). nSequence enables absolute locktime.", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "sequence": 4294967294, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<()>", + "value": "Right(())", + "description": "Selects loan_liquidation_path (PATH::RIGHT)." + } + } + }, + { + "id": "first_params_in", + "description": "First Parameter NFT in lending ScriptAuth (index 1).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "required_index": 1, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "second_params_in", + "description": "Second Parameter NFT in lending ScriptAuth (index 2).", + "utxo_source": { + "utxo_type": "lending_script_auth" + }, + "required_index": 2, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0" + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT from wallet (index 3).", + "utxo_source": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "collateral_to_lender", + "description": "Collateral sent to lender (output 0).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 0 + }, + { + "id": "first_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "first_params_in.amount_sat", + "required_index": 1 + }, + { + "id": "second_params_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", + "amount_sat": "second_params_in.amount_sat", + "required_index": 2 + }, + { + "id": "lender_nft_burned", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 3 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + }, + "ClaimPrincipalWithInterest": { + "description": "Lender withdraws principal+interest from the lender_principal_vault by co-spending and burning the Lender NFT.", + "inputs": [ + { + "id": "vault_in", + "description": "The lender_principal_vault UTXO (index 0).", + "utxo_source": { + "utxo_type": "lender_principal_vault" + }, + "required_index": 0, + "witnesses": { + "INPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1" + }, + "OUTPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1" + } + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT (amount=1), index 1.", + "utxo_source": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1 + }, + { + "id": "fee_input", + "description": "Wallet L-BTC for fees.", + "utxo_source": "wallet", + "asset": "lbtc" + } + ], + "outputs": [ + { + "id": "principal_interest_out", + "description": "Principal+interest delivered to lender (output 0).", + "destination": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "vault_in.amount_sat" + }, + { + "id": "lender_nft_burned", + "description": "Lender NFT burned (required by WITH_ASSET_BURN=true), output 1.", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1 + }, + { + "id": "fee_change", + "destination": "change", + "asset": "lbtc", + "optional": true + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/packages/tx-manifest/src/__fixtures__/current/lending_v3.manifest.json b/packages/tx-manifest/src/__fixtures__/current/lending_v3.manifest.json new file mode 100644 index 0000000..7e520e6 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/current/lending_v3.manifest.json @@ -0,0 +1,1456 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "$comment": "lending_v3 targets the DEPLOYED simplicity-lending indexer (odev). Interop constants are fixed: factory params (2,0), NUMS key, protocol-fee keeper 38fca2d9…, and the program-id tags below. Change a covenant .simf and you must recompute the matching *_PROGRAM_ID + covenant address.", + "manifest_version": "0.1.0", + "protocol": "simplicity-lending", + "simplicity_hl": { "debug_symbols": true }, + "description": "lending_v3 — the redesigned 'issuance factory' lending protocol, wire-compatible with the deployed simplicity-lending indexer/site (odev branch). Phase 3a models factory creation: a persistent issuance_factory covenant plus the wallet-held auth NFT, from which many lending offers are later minted.", + "utxo_types": { + "issuance_factory": { + "description": "The persistent issuance-factory covenant. Holds 1 unit of the factory asset and, when spent (IssueAssets path), mints borrower/lender NFTs for a new offer while recreating itself. Its address depends ONLY on (ISSUING_UTXOS_COUNT, REISSUANCE_FLAGS) — NOT on the factory asset id — so for the deployed (2, 0) it is the fixed spk 5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b (verified reproduced by examples/factory_recon.rs). Compiled with debug symbols to match the deployed CMR.", + "script": { + "type": "simplicity", + "source": "./issuance_factory.simf", + "compile_params": { + "ISSUING_UTXOS_COUNT": "ISSUING_UTXOS_COUNT", + "REISSUANCE_FLAGS": "REISSUANCE_FLAGS" + } + }, + "asset": "FACTORY_ASSET_ID", + "confidential": false + }, + "lending_collateral": { + "description": "The lending (collateral) covenant — out[5] of an offer-creation tx. Holds COLLATERAL_AMOUNT of the collateral asset with 2 taproot storage slots (slot0 = is_active, slot1 = current_debt). Its address is the CMR of lending.simf compiled with the full offer params + the 5 nested AssetAuth/AssetAuthVault cov-hashes (task 07), folded with the 2 storage leaves (task 01/02). Reproduced byte-exactly for live offer 43ab4efe (examples/lending_recon.rs). NOTE: slot1 (current_debt) is dynamic; expressing it as a computed 32-byte storage leaf needs the engine extension tracked in upnext/10.", + "script": { + "type": "simplicity", + "source": "./lending.simf", + "compile_params": { + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", + "BORROWER_NFT_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "LOAN_EXPIRATION_TIME", + "LENDER_VAULT_COV_HASH": "LENDER_VAULT_COV_HASH", + "FINALIZED_LENDER_VAULT_COV_HASH": "FINALIZED_LENDER_VAULT_COV_HASH", + "PROTOCOL_FEE_VAULT_COV_HASH": "PROTOCOL_FEE_VAULT_COV_HASH", + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "instance.CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "lending_collateral_active": { + "description": "The lending (collateral) covenant in the ACTIVE state (after AcceptOffer). Identical to lending_collateral except storage slot0 = is_active = 1 (value 1, byte[31]=0x01), which changes the covenant address. slot1 = CURRENT_DEBT (unchanged on a fresh accept). De-risked by examples/lending_active_recon.rs (address flips vs pending).", + "script": { + "type": "simplicity", + "source": "./lending.simf", + "compile_params": { + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", + "BORROWER_NFT_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "LOAN_EXPIRATION_TIME", + "LENDER_VAULT_COV_HASH": "LENDER_VAULT_COV_HASH", + "FINALIZED_LENDER_VAULT_COV_HASH": "FINALIZED_LENDER_VAULT_COV_HASH", + "PROTOCOL_FEE_VAULT_COV_HASH": "PROTOCOL_FEE_VAULT_COV_HASH", + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000001" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "instance.CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID", + "confidential": false + }, + "principal_asset_auth": { + "description": "AssetAuth covenant holding the loan principal after activation — out[1] of AcceptOffer. Keyed by the borrower NFT (AssetAuth(borrower_nft, amount 1, with_asset_burn=false)); the borrower later claims the principal by presenting the borrower NFT. sha256(its spk) == PRINCIPAL_OUTPUT_SCRIPT_HASH.", + "script": { + "type": "simplicity", + "source": "./asset_auth.simf", + "compile_params": { + "ASSET_ID": "BORROWER_NFT_ASSET_ID", + "ASSET_AMOUNT": "1", + "WITH_ASSET_BURN": "false" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + }, + "lender_vault_finalized": { + "description": "The lender's settlement vault in its FINALIZED state — RepayLoan out[1]. A FinalizedAssetAuthVault(vault=principal, keeper=lender_nft, supplier=borrower_nft, keeper_burn=true, supplier_burn=true, is_active=false, finalized=ZERO_HASH) holding the whole repayment less the protocol fee. 'Finalized' = the debt is fully cleared, so the vault no longer accepts supply; the lender withdraws from it by presenting (and burning) the lender NFT. The lending covenant hard-codes sha256(this spk) as FINALIZED_LENDER_VAULT_COV_HASH and enforces it on the repayment output, so this utxo_type MUST compile to that exact hash — asserted by lending_v3_repay_loan_vault_outputs_match_covenant_hashes.", + "script": { + "type": "simplicity", + "source": "./asset_auth_vault.simf", + "compile_params": { + "VAULT_ASSET_ID": "PRINCIPAL_ASSET_ID", + "KEEPER_AUTH_ASSET_ID": "LENDER_NFT_ASSET_ID", + "SUPPLIER_AUTH_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "KEEPER_AUTH_ASSET_AMOUNT": "1", + "FINALIZED_VAULT_COV_HASH": "ZERO_HASH", + "IS_ACTIVE": "false", + "WITH_KEEPER_ASSET_BURN": "true", + "WITH_SUPPLIER_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + }, + "protocol_fee_vault_finalized": { + "description": "The protocol-fee settlement vault in its FINALIZED state — RepayLoan out[2]. Same shape as lender_vault_finalized but keyed to the protocol-fee keeper asset and with keeper_burn=FALSE (the keeper asset is a shared, reusable fee-collection NFT, so it is preserved rather than burned on withdrawal). Holds the 10% protocol cut of the loan interest. The lending covenant hard-codes sha256(this spk) as FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH and enforces it on the repayment output.", + "script": { + "type": "simplicity", + "source": "./asset_auth_vault.simf", + "compile_params": { + "VAULT_ASSET_ID": "PRINCIPAL_ASSET_ID", + "KEEPER_AUTH_ASSET_ID": "PROTOCOL_FEE_KEEPER_ASSET_ID", + "SUPPLIER_AUTH_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "KEEPER_AUTH_ASSET_AMOUNT": "1", + "FINALIZED_VAULT_COV_HASH": "ZERO_HASH", + "IS_ACTIVE": "false", + "WITH_KEEPER_ASSET_BURN": "false", + "WITH_SUPPLIER_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID", + "confidential": false + }, + "lender_nft_script_auth": { + "description": "ScriptAuth covenant holding the lender NFT during a pending offer — out[3]. Keyed by SCRIPT_HASH = sha256(lending covenant scriptPubKey, INCLUDING its 2 storage slots) i.e. the pending offer's own script hash (ScriptAuth::from_simplex_program). NOTE: the manifest's tapleaf/script-hash compute currently hashes the storage-less covenant; hashing a covenant-with-storage needs the engine extension tracked in upnext/11.", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "compile_params": { + "SCRIPT_HASH": "LENDING_COV_SCRIPT_HASH" + } + }, + "asset": "LENDER_NFT_ASSET_ID", + "confidential": false + } + }, + "actions": {}, + "contract_templates": { + "issuance_factory": { + "description": "A persistent issuance factory. Created once (CreateFactory); the owner holds the auth NFT and reuses the factory covenant to mint many lending offers. The deployed indexer's FactoryCreationsTracker is seeded with hardcoded (issuing_utxos_count=2, reissuance_flags=0) — offers minted from a factory with any other parameters will not be detected.", + "fields": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset id of the factory asset, issued (total amount 2) in the creation tx. One unit stays in the factory covenant, one is the wallet-held auth NFT. Derived from the issuance input's outpoint + entropy and captured on_resolved." + }, + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "description": "Number of UTXOs the factory may use for issuance in a single tx. Baked into the covenant (param::ISSUING_UTXOS_COUNT). Must be 2 to match the deployed indexer." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "description": "Per-slot reissuance bitmask baked into the covenant (param::REISSUANCE_FLAGS). Must be 0 to match the deployed indexer." + } + }, + "actions": { + "CreateFactory": { + "description": "Issue the factory asset (total 2) from a wallet L-BTC UTXO, keep 1 unit as the owner's auth NFT (out[0], wallet p2wpkh), lock 1 unit in the issuance_factory covenant (out[1]), and publish the 13-byte creation OP_RETURN (out[2]). This layout is exactly what the deployed indexer's FactoryCreationsTracker requires: IssuanceFactory::try_from_tx reads the metadata at output index 2, verifies program_id + params, then finds the single amount-1 program output (covenant) and the single amount-1 auth output. Writes the instance file recording FACTORY_ASSET_ID.", + "params": { + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "default": "2", + "description": "Factory issuing-UTXOs count. Leave at the default 2 — the deployed indexer only detects factories (and their offers) with issuing_utxos_count == 2." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "default": "0", + "description": "Factory reissuance-flags bitmask. Leave at the default 0 — the deployed indexer only detects factories with reissuance_flags == 0." + }, + "FACTORY_PROGRAM_ID": { + "type": "bytes", + "default": "dd1e7f89", + "description": "The 4-byte protocol message-type tag that prefixes the factory-creation OP_RETURN (out[2]). The indexer's IssuanceFactory::try_from_tx rejects the tx unless these bytes match. This is a fixed constant, NOT a per-run input — do not override. How it is derived (recompute ONLY if you edit issuance_factory.simf, which also changes the covenant address): the first 4 bytes of SHA-256 of the LF-normalized issuance_factory.simf source text. (Value verified against on-chain data; the engine no longer computes it — it's a plain constant so the tx-encoder stays protocol-agnostic.)" + } + }, + "create_instance": { + "fields": { + "FACTORY_ASSET_ID": "$instance.FACTORY_ASSET_ID", + "ISSUING_UTXOS_COUNT": "$params.ISSUING_UTXOS_COUNT", + "REISSUANCE_FLAGS": "$params.REISSUANCE_FLAGS" + } + }, + "inputs": [ + { + "id": "factory_issuance_input", + "description": "Wallet L-BTC UTXO whose outpoint (+ entropy) determines FACTORY_ASSET_ID. Carries a NEW issuance of the factory asset, amount 2 (FACTORY_ASSET_TOTAL_AMOUNT), 0 reissuance tokens — matching cli/commands/factory/core.rs::create.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 2, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.FACTORY_ASSET_ID": "asset" + } + }, + "ui": { + "label": "the L-BTC input that mints the factory asset", + "role": "issuance" + } + } + ], + "outputs": [ + { + "id": "auth_nft_out", + "description": "The owner's auth NFT: 1 unit of the factory asset paid to the wallet's index-0 P2WPKH (output 0). Whoever holds this authorizes minting offers from the factory.", + "destination": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "your factory auth NFT — keep it to mint offers", + "role": "auth_nft" + } + }, + { + "id": "factory_program_out", + "description": "1 unit of the factory asset locked in the issuance_factory covenant (output 1). This is the persistent program UTXO the indexer tracks and later offers spend + recreate.", + "destination": { + "utxo_type": "issuance_factory" + }, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "the factory covenant — recreated on every mint", + "role": "covenant" + } + }, + { + "id": "creation_op_return", + "description": "Creation metadata OP_RETURN (output 2, index-fixed as CREATION_METADATA_OUTPUT_INDEX). 13 bytes: program_id (4, sha256(issuance_factory.simf source)[..4]) || issuing_utxos_count (1, u8) || reissuance_flags (8, u64 LE). Exactly IssuanceFactoryCreationMetadata::encode.", + "destination": { + "type": "op_return" + }, + "data": { + "parts": [ + { + "type": "bytes", + "value": "instance.FACTORY_PROGRAM_ID" + }, + { + "type": "u8", + "value": "instance.ISSUING_UTXOS_COUNT" + }, + { + "type": "u64", + "value": "instance.REISSUANCE_FLAGS", + "endian": "le" + } + ] + }, + "required_index": 2, + "ui": { + "label": "indexer data: this factory and its parameters", + "role": "protocol_data", + "group": "burned / protocol data" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "intent": "create a reusable issuance factory — the minter you will create lending offers from" + } + } + }, + "lending_contract": { + "description": "A single P2P lending offer minted from an issuance factory (lending_v3). CreateOffer computes the lending (collateral) covenant address (out[5]) from the offer terms and the nested AssetAuth/AssetAuthVault cov-hash chain (task 07), matching simplicity-lending's LendingOfferParameters::build_arguments. The 5 nested hashes reproduce live offer 43ab4efe out[5] byte-exactly (examples/lending_recon.rs). Full on-chain assembly (dynamic storage leaves, script_auth-over-storage) is tracked in task 06 + upnext/10-11.", + "fields": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The issuance factory's asset id (from the factory instance). Its auth NFT is spent in[0]/recreated out[0], and the factory covenant (in[1]) is recreated out[1]." + }, + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "description": "Factory issuing-UTXOs count (constant 2). Needed to compute the issuance_factory covenant address for in[1]/out[1]." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "description": "Factory reissuance-flags bitmask (constant 0). Needed to compute the issuance_factory covenant address for in[1]/out[1]." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Collateral asset id." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Loan (principal) asset id." + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Borrower NFT, issued from the factory covenant (in[1]) at offer creation." + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Lender NFT, issued from the collateral input (in[2]) at offer creation." + }, + "PROTOCOL_FEE_KEEPER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Protocol-fee keeper asset id. A fixed asset configured in the deployed indexer's OffersCreationTracker; the offer's lending covenant bakes in its AssetAuthVault hashes." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Collateral amount (base units) locked in out[5]." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Principal requested (base units)." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "description": "Interest rate in basis points. NOTE u64 in v3 (build_arguments casts the u16 OfferParameters value to u64) — a CMR-relevant change from v2's u16." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "CLTV block height for liquidation." + }, + "FINALIZED_LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of FinalizedAssetAuthVault(vault=principal, keeper=lender_nft, supplier=borrower_nft, keeper_burn=true, supplier_burn=true, is_active=false, finalized=0)." + }, + "LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of the ACTIVE lender AssetAuthVault (same params, is_active=true, finalized=FINALIZED_LENDER_VAULT_COV_HASH)." + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of FinalizedAssetAuthVault(vault=principal, keeper=protocol_fee_keeper, supplier=borrower_nft, keeper_burn=FALSE, supplier_burn=true, is_active=false, finalized=0)." + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of the ACTIVE protocol-fee AssetAuthVault (same params, is_active=true, finalized=FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH)." + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "description": "sha256(spk) of AssetAuth(borrower_nft, amount 1, with_asset_burn=false). The principal is paid to this borrower-NFT-keyed AssetAuth covenant (v3 replaces v2's wallet-hash borrower_output_script_hash)." + }, + "CURRENT_DEBT": { + "type": "u64", + "description": "Total amount to repay = principal + interest, where interest = principal * PRINCIPAL_INTEREST_RATE / 10000 (integer division, = get_total_amount_to_repay / apply_basis_points). Encoded as u64 BE in bytes[24..32] of the lending covenant's storage slot1 (out[5])." + }, + "LENDING_COV_SCRIPT_HASH": { + "type": "bytes32", + "description": "sha256(scriptPubKey) of the pending lending covenant INCLUDING its 2 storage slots (= sha256 of out[5]'s spk). The lender_nft_script_auth covenant (out[3]) commits to this via ScriptAuth::from_simplex_program(pending_offer). Computed with a tapleaf-over-lending.simf that folds the same storage leaves (task 11)." + }, + "ZERO_HASH": { + "type": "bytes32", + "default": "0000000000000000000000000000000000000000000000000000000000000000", + "description": "The all-zero FINALIZED_VAULT_COV_HASH baked into the finalized (non-active) AssetAuthVault variants. A fixed constant, kept as a field so the *_vault_finalized utxo_types can reference it by name and inherit its declared bytes32 type. Do not inline the zeros as a literal in compile_params: 64 zero characters are all ASCII digits, so the engine's value-based type inference would type it u64 and mis-compile the covenant." + } + }, + "actions": { + "CreateOffer": { + "description": "Mint a pending offer from the factory: spend the factory auth NFT (in[0]) + factory covenant issuing the borrower NFT (in[1], IssueAssets{output_index:0}) + collateral input issuing the lender NFT (in[2]); recreate the factory covenant (out[1]), pay borrower NFT to wallet (out[2]), lock lender NFT in a ScriptAuth keyed to the offer (out[3]), publish the 50-byte lending metadata OP_RETURN (out[4]), and lock collateral in the lending covenant with 2 storage slots (out[5]). create_instance computes the nested cov-hash chain that fixes the out[5] address (task 07).", + "params": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The issuance factory's asset id (from the factory instance file). Its auth NFT is spent in[0] and recreated out[0]/out[1]." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Collateral asset id (e.g. L-BTC)." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Loan asset id." + }, + "PROTOCOL_FEE_KEEPER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Protocol-fee keeper asset id (must match the deployed indexer's configured value)." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Collateral amount (base units)." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Principal amount (base units)." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "description": "Interest rate in basis points (u64 in v3)." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "Liquidation CLTV height." + }, + "ZERO_HASH": { + "type": "bytes32", + "default": "0000000000000000000000000000000000000000000000000000000000000000", + "description": "The all-zero finalized_vault_cov_hash used by finalized (non-active) AssetAuthVault variants. Constant; do not override." + }, + "LENDING_PROGRAM_ID": { + "type": "bytes", + "default": "f80c6162", + "description": "The 4-byte protocol message-type tag that prefixes the offer-creation OP_RETURN (out[4]); the indexer keys offer detection on it. Fixed constant, NOT a per-run input — do not override. Derived (recompute ONLY if you edit lending.simf, which also changes the covenant address) as the first 4 bytes of SHA-256 of the LF-normalized lending.simf source text. Verified against on-chain data; the engine treats it as a plain constant so the tx-encoder stays protocol-agnostic." + } + }, + "create_instance": { + "fields": { + "FACTORY_ASSET_ID": "$params.FACTORY_ASSET_ID", + "ISSUING_UTXOS_COUNT": "2", + "REISSUANCE_FLAGS": "0", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "$params.PRINCIPAL_ASSET_ID", + "PROTOCOL_FEE_KEEPER_ASSET_ID": "$params.PROTOCOL_FEE_KEEPER_ASSET_ID", + "ZERO_HASH": "$params.ZERO_HASH", + "BORROWER_NFT_ASSET_ID": "$instance.BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "$instance.LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "$params.PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "$params.PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "$params.LOAN_EXPIRATION_TIME", + "CURRENT_DEBT": "params.PRINCIPAL_AMOUNT + params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000", + "FINALIZED_LENDER_VAULT_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth_vault.simf", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "ZERO_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "false" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "true" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "LENDER_VAULT_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth_vault.simf", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_LENDER_VAULT_COV_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "true" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "true" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth_vault.simf", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PROTOCOL_FEE_KEEPER_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "ZERO_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "false" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "false" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth_vault.simf", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PROTOCOL_FEE_KEEPER_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "true" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "false" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "tapleaf", + "simf": "./asset_auth.simf", + "params": { + "ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "WITH_ASSET_BURN": { + "type": "bool", + "value": "false" + } + } + }, + "LENDING_COV_SCRIPT_HASH": { + "type": "tapleaf", + "simf": "./lending.simf", + "params": { + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "value": "LENDER_VAULT_COV_HASH" + }, + "FINALIZED_LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_LENDER_VAULT_COV_HASH" + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "value": "PROTOCOL_FEE_VAULT_COV_HASH" + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + } + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + } + } + }, + "inputs": [ + { + "id": "factory_auth_in", + "description": "Factory auth NFT (1 unit of the factory asset) from the wallet — authorizes minting an offer. Recreated at out[0] (input 0).", + "utxo_source": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "auth NFT for issuance factory", + "role": "auth_nft" + } + }, + { + "id": "factory_covenant_in", + "description": "The issuance_factory covenant UTXO (input 1). Spent via the IssueAssets path, issuing the borrower NFT from this covenant's outpoint; the covenant is recreated at out[1]. Covenant-sourced issuance — task 03.", + "utxo_source": { + "utxo_type": "issuance_factory" + }, + "required_index": 1, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<u32, u32>", + "value": "Left(0)", + "description": "IssueAssets path (PATH::LEFT). The u32 payload is the auth-NFT output index (0)." + } + }, + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.BORROWER_NFT_ASSET_ID": "asset" + } + }, + "ui": { + "label": "issuance factory covenant", + "role": "covenant" + } + }, + { + "id": "collateral_in", + "description": "Borrower's collateral wallet UTXO (input 2), sized >= COLLATERAL_AMOUNT. Also carries the lender-NFT issuance (issued from this input's outpoint).", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.COLLATERAL_AMOUNT" + }, + "required_index": 2, + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.LENDER_NFT_ASSET_ID": "asset" + } + }, + "ui": { + "label": "collateral to be locked for the loan", + "role": "collateral" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "factory_auth_out", + "description": "Factory auth NFT (1) back to the wallet (output 0). This is the auth-NFT output index the IssueAssets witness commits to.", + "destination": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "auth NFT for issuance factory (returned)", + "role": "auth_nft" + } + }, + { + "id": "factory_covenant_out", + "description": "The issuance_factory covenant recreated (output 1), holding 1 unit of the factory asset.", + "destination": { + "utxo_type": "issuance_factory" + }, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "issuance factory covenant", + "role": "covenant" + } + }, + { + "id": "borrower_nft_out", + "description": "Borrower NFT (1) to the wallet (output 2).", + "destination": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 2, + "ui": { + "label": "new NFT used for claiming the principal later", + "role": "borrower_nft" + } + }, + { + "id": "lender_nft_locked", + "description": "Lender NFT (1) locked in the lender_nft_script_auth covenant (output 3), keyed to the pending offer's own script hash (LENDING_COV_SCRIPT_HASH).", + "destination": { + "utxo_type": "lender_nft_script_auth" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 3, + "ui": { + "label": "newly created NFT to be sent to the lender", + "role": "lender_nft" + } + }, + { + "id": "creation_op_return", + "description": "Lending creation metadata OP_RETURN (output 4), 50 bytes: program_id(lending.simf)[4] || principal_asset_id[32, internal] || principal_amount[8, u64 LE] || loan_expiration[4, u32 LE] || interest_rate[2, u16 LE]. Matches lending metadata::encode (task 04). Interest rate is u16 in the OP_RETURN even though it is u64 in the covenant params.", + "destination": { + "type": "op_return" + }, + "data": { + "$comment": "50-byte layout must match lending metadata::encode exactly, in this order.", + "parts": [ + { + "type": "bytes", + "value": "instance.LENDING_PROGRAM_ID", + "$comment": "4-byte message-type tag; see the LENDING_PROGRAM_ID param for how it's derived." + }, + { + "type": "liquid.asset_id", + "value": "instance.PRINCIPAL_ASSET_ID" + }, + { + "type": "u64", + "value": "instance.PRINCIPAL_AMOUNT" + }, + { + "type": "u32", + "value": "instance.LOAN_EXPIRATION_TIME" + }, + { + "type": "u16", + "value": "instance.PRINCIPAL_INTEREST_RATE" + } + ] + }, + "required_index": 4, + "ui": { + "$comment": "Full structured UI hint (all fields). A bare string is shorthand for just `label`. `role` is a semantic tag; `group` overrides the derived net-effect account heading; `hide: true` drops this leg from the diff entirely.", + "label": "indexer data: the offer and its terms", + "role": "protocol_data", + "group": "burned / protocol data", + "hide": false + } + }, + { + "id": "lending_collateral_out", + "description": "Collateral locked in the lending covenant (output 5), COLLATERAL_AMOUNT with 2 pending storage slots (is_active=0, current_debt).", + "destination": { + "utxo_type": "lending_collateral" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 5, + "ui": { + "label": "collateral prelocked in the lending covenant", + "role": "collateral" + } + }, + { + "id": "collateral_change", + "description": "Collateral-asset change back to the wallet.", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the collateral", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "intent": "create an offer to borrow {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol} by locking {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} as collateral" + }, + "AcceptOffer": { + "description": "A lender activates a pending offer (offer_open → loan_active). Spends the pending lending covenant (in[0], Accept witness Left(Left())) and the lender NFT locked in the ScriptAuth (in[1], authorizing input 0), supplies the principal (in[2]). Outputs: the lending covenant re-committed ACTIVE (out[0], storage is_active=1) still holding the collateral; the principal paid into an AssetAuth(borrower_nft) covenant the borrower can later claim (out[1]); the lender NFT returned to the lender's wallet (out[2]). Mirrors LendingOffer::attach_acceptance. Loads the offer instance written by CreateOffer.", + "inputs": [ + { + "id": "pending_offer_in", + "description": "The pending lending (collateral) covenant UTXO (input 0). Accept path.", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<Either<(), ()>, Either<Either<(u64, u64), u64>, u64>>", + "value": "Left(Left(()))", + "description": "OfferAcceptance (PATH::Left(Left(())))." + } + }, + "ui": { + "label": "the open offer you are accepting", + "role": "covenant" + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT held in the lender_nft_script_auth covenant (input 1). The ScriptAuth authorizes its spend by checking input 0 is the pending offer covenant.", + "utxo_source": { + "utxo_type": "lender_nft_script_auth" + }, + "required_index": 1, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0", + "description": "Index of the pending-offer covenant input this ScriptAuth is bound to (input 0)." + } + }, + "ui": { + "label": "the lender NFT — your claim on this loan", + "role": "lender_nft" + } + }, + { + "id": "principal_in", + "description": "Lender's wallet UTXO providing the loan principal (input 2), sized >= PRINCIPAL_AMOUNT.", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT" + }, + "required_index": 2, + "ui": { + "label": "the principal you are lending", + "role": "principal" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "active_offer_out", + "description": "The lending covenant re-committed in the ACTIVE state (output 0), still holding COLLATERAL_AMOUNT of collateral; storage slot0 = is_active = 1.", + "destination": { + "utxo_type": "lending_collateral_active" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 0, + "ui": { + "label": "the loan, now live — holds the collateral", + "role": "covenant" + } + }, + { + "id": "principal_out", + "description": "The principal paid into an AssetAuth(borrower_nft) covenant (output 1), PRINCIPAL_AMOUNT. The borrower claims it later with the borrower NFT. Enforced by the covenant's PRINCIPAL_OUTPUT_SCRIPT_HASH.", + "destination": { + "utxo_type": "principal_asset_auth" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 1, + "ui": { + "label": "the principal, locked where only the borrower can claim it", + "role": "principal" + } + }, + { + "id": "lender_nft_out", + "description": "Lender NFT (1) returned to the lender's wallet (output 2) — unlocked from the ScriptAuth on activation.", + "destination": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 2, + "ui": { + "label": "your lender NFT — keep it, it is how you collect the repayment", + "role": "lender_nft" + } + }, + { + "id": "principal_change", + "description": "Principal-asset change back to the lender.", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the principal", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the lender.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "intent": "lend {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol} against {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} of collateral" + }, + "CancelOffer": { + "description": "The borrower withdraws a pending offer before activation (offer_open → cancelled). Spends the pending lending covenant (in[0], Cancel witness Left(Right())) and the lender NFT from its ScriptAuth (in[1]) and the borrower NFT from the wallet (in[2]); burns both NFTs to OP_RETURN (out[0] lender, out[1] borrower — index-fixed by the covenant) and returns the collateral to the borrower (out[2]). Mirrors LendingOffer::attach_cancellation. The covenant only checks each NFT lands on an OP_RETURN, so data-less burns suffice.", + "inputs": [ + { + "id": "pending_offer_in", + "description": "The pending lending (collateral) covenant UTXO (input 0). Cancel path.", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<Either<(), ()>, Either<Either<(u64, u64), u64>, u64>>", + "value": "Left(Right(()))", + "description": "OfferCancellation (PATH::Left(Right(())))." + } + }, + "ui": { + "label": "your open offer — the covenant holding your collateral", + "role": "covenant" + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT held in the lender_nft_script_auth covenant (input 1); burned at out[0].", + "utxo_source": { + "utxo_type": "lender_nft_script_auth" + }, + "required_index": 1, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0", + "description": "The pending-offer covenant input this ScriptAuth is bound to (input 0)." + } + }, + "ui": { + "label": "the lender NFT — destroyed, nobody took the offer", + "role": "lender_nft" + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT from the wallet (input 2); burned at out[1].", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 2, + "ui": { + "label": "your borrower NFT — destroyed below", + "role": "borrower_nft" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "lender_nft_burned", + "description": "Lender NFT burned to OP_RETURN (output 0).", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "lender NFT destroyed — the offer can never be accepted now", + "role": "lender_nft" + } + }, + { + "id": "borrower_nft_burned", + "description": "Borrower NFT burned to OP_RETURN (output 1).", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your borrower NFT, destroyed", + "role": "borrower_nft" + } + }, + { + "id": "collateral_returned", + "description": "Collateral returned to the borrower's wallet (output 2).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 2, + "ui": { + "label": "your collateral, returned to you", + "role": "collateral" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "intent": "cancel your offer and take back the {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} collateral" + }, + "ClaimPrincipal": { + "intent": "claim the loan principal of {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol}", + "description": "The borrower withdraws the loan principal from the AssetAuth(borrower_nft) covenant created at activation (AcceptOffer out[1]). Spends that covenant (in[0]) while presenting the borrower NFT (in[1], INPUT_ASSET_INDEX=1) and preserving it (out[1], OUTPUT_ASSET_INDEX=1); the covenant only checks the auth NFT is present and re-output (WITH_ASSET_BURN=false), so the principal (out[0]) flows freely to the borrower. Mirrors AssetAuth::attach_unlocking (unlocks_without_burn_with_one_explicit_output). The offer stays active (loan_active → loan_active); collateral is unaffected.", + "inputs": [ + { + "id": "principal_asset_auth_in", + "description": "The AssetAuth(borrower_nft) covenant UTXO holding the principal (input 0).", + "utxo_source": { + "utxo_type": "principal_asset_auth" + }, + "required_index": 0, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "witnesses": { + "INPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1", + "description": "Index of the input carrying the borrower auth NFT (input 1)." + }, + "OUTPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1", + "description": "Index of the output the borrower auth NFT is preserved at (output 1)." + } + }, + "ui": { + "label": "the covenant holding your borrowed funds", + "role": "covenant" + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower auth NFT from the wallet (input 1) — proves the borrower and is re-output at out[1].", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your borrower NFT — returned to you below", + "role": "borrower_nft" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "principal_claimed", + "description": "The loan principal released to the borrower's wallet (output 0), PRINCIPAL_AMOUNT.", + "destination": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 0, + "ui": { + "label": "the borrowed funds, released to you", + "role": "principal" + } + }, + { + "id": "borrower_nft_out", + "description": "Borrower auth NFT (1) preserved back to the wallet (output 1) — checked by the covenant.", + "destination": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "your borrower NFT — you need it to repay", + "role": "borrower_nft" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ] + }, + "RepayLoan": { + "intent": "repay the loan in full — {instance.CURRENT_DEBT} {instance.PRINCIPAL_ASSET_ID:symbol} (principal + interest) — and release the {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} collateral", + "description": "The borrower clears the whole debt in one transaction and takes the collateral back (loan_active → repaid). Spends the ACTIVE lending covenant (in[1], FullRepayment witness) while presenting and BURNING the borrower NFT (in[0]/out[0]) — burning the NFT is what makes this final: it is the auth token for ClaimPrincipal and for supplying the vaults, so no further action on the offer is possible. The repayment is split into two freshly-created settlement vaults the covenant's baked-in hashes pin exactly: the lender's (out[1], debt minus the protocol fee) and the protocol's (out[2], 10% of the interest). The collateral (out[3]) is then unconstrained and goes back to the borrower's wallet. Mirrors LendingOffer::attach_full_repayment + attach_vaults in the NoRepayments phase. SCOPE: this models a full repayment of an UNTOUCHED active loan (no prior partial repayment), which is the case the covenant's validate_vaults reaches via `already_repaid_amount == 0` — the branch that CREATES the vaults as plain outputs rather than spending existing ones. A repayment after a partial one must instead spend + top up the live vaults (AssetAuthVault supply/final_supply witnesses); see backlog/08.", + "params": { + "TOTAL_PROTOCOL_FEE": { + "type": "u64", + "compute": "instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000", + "description": "The protocol's cut, paid to out[2]: 10% (PROTOCOL_FEE_BPS=1000) of the total loan interest, where interest = PRINCIPAL_AMOUNT * PRINCIPAL_INTEREST_RATE / 10000. Auto-computed; do not override — the covenant recomputes it and rejects any other split. Both divisions floor, and in this order, exactly as lending.simf's apply_basis_points chain does (get_protocol_fee_amount(get_total_fee_amount())); folding them into one division would round differently." + }, + "LENDER_VAULT_AMOUNT": { + "type": "u64", + "compute": "instance.CURRENT_DEBT - instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000", + "description": "The lender's share, paid to out[1]: the entire debt less TOTAL_PROTOCOL_FEE (= the covenant's additional_lender_vault_amount = amount_to_repay - protocol_fee_repaid). Auto-computed; do not override. Spelled out from instance fields rather than referencing TOTAL_PROTOCOL_FEE so the two formulas are independent of the order params are evaluated in." + } + }, + "inputs": [ + { + "id": "borrower_nft_in", + "description": "Borrower NFT from the wallet (input 0) — authorizes the repayment and is burned at out[0]. The covenant fixes this at input 0 (full_repay_offer's borrower_nft_input_index).", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your borrower NFT — burned, closing the loan", + "role": "borrower_nft" + } + }, + { + "id": "active_offer_in", + "description": "The ACTIVE lending (collateral) covenant UTXO (input 1) — storage is_active=1, current_debt=CURRENT_DEBT. FullRepayment path. The covenant asserts its own input index is 1 and that its script hash commits to storage(true, current_debt), which is what ties the witness's debt payload to reality.", + "utxo_source": { + "utxo_type": "lending_collateral_active" + }, + "required_index": 1, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either<Either<(), ()>, Either<Either<(u64, u64), u64>, u64>>", + "value": "Right(Left(Right(instance.CURRENT_DEBT)))", + "description": "FullRepayment (PATH::Right(Left(Right(current_debt)))). The u64 payload is the debt being cleared; it is not trusted input — the covenant recomputes the input's expected script hash from it and compares against the real one." + } + }, + "ui": { + "label": "the live loan — the covenant holding your collateral", + "role": "covenant" + } + }, + { + "id": "repayment_in", + "description": "The borrower's principal-asset UTXO funding the repayment (input 2), sized >= CURRENT_DEBT. The covenant does not constrain this input (in this branch input indexes 2/3 are the unused vault-input slots); the repayment is enforced on the vault OUTPUTS instead, so any funding source works.", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.CURRENT_DEBT" + }, + "required_index": 2, + "ui": { + "label": "your funds to repay — principal plus interest", + "role": "repayment" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "borrower_nft_burned", + "description": "Borrower NFT burned to OP_RETURN (output 0) — covenant-fixed index. Data-less burn; the covenant only checks is_op_return.", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your borrower NFT — destroyed, closing the loan", + "role": "borrower_nft", + "group": "burned / protocol data" + } + }, + { + "id": "lender_vault_out", + "description": "The lender's finalized settlement vault (output 1), holding LENDER_VAULT_AMOUNT of the principal asset. Covenant-fixed: full_repay_offer's lender-vault output index is 1, and validate_vaults pins the script hash to FINALIZED_LENDER_VAULT_COV_HASH (finalized, not active, because current_debt == amount_to_repay). The lender collects it later with the lender NFT.", + "destination": { + "utxo_type": "lender_vault_finalized" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "params.LENDER_VAULT_AMOUNT", + "confidential": false, + "required_index": 1, + "ui": { + "label": "the lender's repayment, parked for them to collect", + "role": "vault" + } + }, + { + "id": "protocol_fee_vault_out", + "description": "The protocol-fee finalized settlement vault (output 2), holding TOTAL_PROTOCOL_FEE of the principal asset. Covenant-fixed index 2; script hash pinned to FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH (finalized because this repayment clears the entire protocol fee at once).", + "destination": { + "utxo_type": "protocol_fee_vault_finalized" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "params.TOTAL_PROTOCOL_FEE", + "confidential": false, + "required_index": 2, + "ui": { + "label": "the protocol's fee — 10% of the interest", + "role": "vault" + } + }, + { + "id": "collateral_returned", + "description": "The collateral released back to the borrower's wallet (output 3), COLLATERAL_AMOUNT. Index 3 is this manifest's choice, not the covenant's: full_repay_offer only checks the collateral on the INPUT side and adds no program output, so once the vaults are satisfied the collateral is unconstrained.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 3, + "ui": { + "label": "your collateral, released back to you", + "role": "collateral" + } + }, + { + "id": "principal_change", + "description": "Principal-asset change back to the borrower (anything above CURRENT_DEBT).", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the repayment", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/packages/tx-manifest/src/__fixtures__/current/zeroconf.manifest.json b/packages/tx-manifest/src/__fixtures__/current/zeroconf.manifest.json new file mode 100644 index 0000000..4113f7f --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/current/zeroconf.manifest.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "manifest_version": "0.1.0", + "protocol": "zeroconf", + "description": "Example zeroconf", + "chain": "liquid", + "utxo_types": {}, + "actions": {} +} \ No newline at end of file diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts new file mode 100644 index 0000000..3e38bf7 --- /dev/null +++ b/packages/tx-manifest/src/currency.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; + +import currentDex from "./__fixtures__/current/dex.manifest.json"; +import currentLastWill from "./__fixtures__/current/last_will.manifest.json"; +import currentLendingV2 from "./__fixtures__/current/lending_v2.manifest.json"; +import currentLendingV3 from "./__fixtures__/current/lending_v3.manifest.json"; +import currentZeroconf from "./__fixtures__/current/zeroconf.manifest.json"; +import frozenDex from "./__fixtures__/dex.manifest.json"; +import frozenLastWill from "./__fixtures__/last_will.manifest.json"; +import frozenLendingV2 from "./__fixtures__/lending_v2.manifest.json"; +import frozenLendingV3 from "./__fixtures__/lending_v3.manifest.json"; +import { normaliseManifest } from "./document/normalise"; +import { inspectConstructs, loadBearing } from "./document/registry"; + +// The same protocols in two generations, which is the only thing that can catch the format +// moving under this wallet. It moved once already: the container holding a contract's actions +// was renamed, every published protocol stopped being readable, and the wallet's own checks +// said nothing because they run against copies frozen before the rename. +// +// So these numbers are the point rather than the assertions around them. When they change, +// the format has moved and someone has to look — which is what did not happen last time. + +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + +const CURRENT = { + dex: currentDex, + last_will: currentLastWill, + lending_v2: currentLendingV2, + lending_v3: currentLendingV3, + zeroconf: currentZeroconf, +} as unknown as Record<string, Record<string, unknown>>; + +const FROZEN = { + dex: frozenDex, + last_will: frozenLastWill, + lending_v2: frozenLendingV2, + lending_v3: frozenLendingV3, +} as unknown as Record<string, Record<string, unknown>>; + +function actionsIn(document: Record<string, unknown>): number { + return normaliseManifest(document).manifest.actions.length; +} + +/** What still stops each action, by the name of the construct that stops it. */ +function blockedBy(document: Record<string, unknown>): string[] { + const { manifest } = normaliseManifest(document); + + return [ + ...new Set(loadBearing(inspectConstructs(manifest)).map((finding) => finding.key)), + ].toSorted(); +} + +describe("the protocols as their authors publish them now", () => { + test("every action is found, in the vocabulary they currently use", () => { + const found = Object.fromEntries( + Object.keys(CURRENT).map((name) => [name, actionsIn(CURRENT[name]!)]), + ); + + expect(found).toEqual({ + dex: 4, + last_will: 4, + lending_v2: 9, + lending_v3: 6, + zeroconf: 0, + }); + }); + + // The rename is added and never traded. A wallet that read only the newer spelling would be + // as blind to the previous generation as it was to this one, and the corpus keeps several + // generations of the same protocol side by side on purpose. + test("and the same count is found in the generation before it", () => { + for (const name of Object.keys(FROZEN)) { + expect(actionsIn(FROZEN[name]!)).toBe(actionsIn(CURRENT[name]!)); + } + }); + + test("nothing in any of them is a field no position describes", () => { + for (const name of Object.keys(CURRENT)) { + const unrecognised = loadBearing( + inspectConstructs(normaliseManifest(CURRENT[name]!).manifest), + ).filter((finding) => !finding.declared); + + expect({ [name]: unrecognised.map((finding) => `${finding.key} at ${finding.at}`) }).toEqual({ + [name]: [], + }); + } + }); + + // Every remaining refusal names a feature this wallet has never built, rather than a word it + // has not been told. Which features, and how many of each, is what says whether performing + // these protocols is close or far. + test("what still stops each one is a feature, and it is named", () => { + const blocked = Object.fromEntries( + Object.keys(CURRENT).map((name) => [name, blockedBy(CURRENT[name]!)]), + ); + + expect(blocked).toEqual({ + dex: [ + "confidential", + "create_instance", + "default", + "required_index", + "simplicity_type", + "value", + ], + last_will: ["create_instance", "default", "required_index", "simplicity_type", "value"], + lending_v2: [ + "compute", + "confidential", + "create_instance", + "default", + "issuance", + "on_pre_broadcast", + "on_resolved", + "required_index", + "simplicity_type", + "value", + ], + lending_v3: [ + "compute", + "confidential", + "create_instance", + "default", + "issuance", + "on_resolved", + "required_index", + "simplicity_type", + "value", + ], + zeroconf: [], + }); + }); + + // The one published protocol this wallet refuses nothing about. It declares no actions, so + // nothing can be performed from it either — which is worth stating rather than letting an + // empty refusal list read as success. + test("and one of them is refused for nothing at all, having nothing in it", () => { + expect(blockedBy(CURRENT.zeroconf!)).toEqual([]); + expect(actionsIn(CURRENT.zeroconf!)).toBe(0); + }); + + test("the build mode a protocol states is read wherever it states it", () => { + const { manifest, notes } = normaliseManifest(CURRENT.lending_v3!); + + expect(manifest.node.compile_debug_symbols).toBe(true); + expect(notes.some((note) => note.found === "simplicity_hl.debug_symbols")).toBe(true); + }); + + test("and a protocol stating it the older way keeps being read", () => { + expect(normaliseManifest(FROZEN.lending_v3!).manifest.node.compile_debug_symbols).toBe(true); + }); +}); + +// Guards the thing that made this invisible: a policy asset is needed to judge these documents +// at all, and every count above is taken with the same one the wallet uses. +test("every count above is taken against the network's own asset", () => { + expect(POLICY_ASSET).toHaveLength(64); +}); diff --git a/packages/tx-manifest/src/document/normalise.ts b/packages/tx-manifest/src/document/normalise.ts index 48b562e..e036a6a 100644 --- a/packages/tx-manifest/src/document/normalise.ts +++ b/packages/tx-manifest/src/document/normalise.ts @@ -89,6 +89,8 @@ export function normaliseManifest(raw: Record<string, unknown>): NormaliseManife delete node.compose_version; delete node.compile_params; + liftBuildMode(node, notes); + if (manifestVersion !== undefined) { node.manifest_version = manifestVersion; } @@ -166,6 +168,30 @@ export function normaliseInstance( }; } +/** + * The build mode moved into a block of its own, and the wallet reads it where it was. + * + * `compile_debug_symbols` at the top level became `simplicity_hl.debug_symbols`. It is not + * cosmetic: the mode changes a contract's commitment root and therefore its address, so a + * document whose statement goes unread is built the other way and refuses against where the + * money actually sits. A document carrying the flat name keeps it — the older spelling is not + * overwritten by a newer copy, which is how every other lift here behaves. + */ +function liftBuildMode(node: Record<string, unknown>, notes: NormalisationNote[]): void { + const block = asRecord(node.simplicity_hl); + + if (!block || !("debug_symbols" in block) || "compile_debug_symbols" in node) { + return; + } + + node.compile_debug_symbols = block.debug_symbols; + notes.push({ + at: "manifest", + canonical: "compile_debug_symbols", + found: "simplicity_hl.debug_symbols", + }); +} + /** * Protocol-level compile parameters. * @@ -195,9 +221,9 @@ function normaliseProtocolParams( } /** - * Both declaration shapes, in declaration order: flat `actions` first, then each class's - * `methods`. A name declared in both resolves to the flat one, which is what every reader - * of this manifest did before the two shapes were unified. + * Every declaration shape, in declaration order: flat `actions` first, then each container's. + * A name declared twice resolves to the flat one, which is what every reader of this manifest + * did before the shapes were unified. */ function normaliseActions( raw: Record<string, unknown>, @@ -217,22 +243,45 @@ function normaliseActions( actions.push(normaliseAction(name, node, undefined, notes)); } - for (const [className, declared] of Object.entries(asRecord(raw.classes) ?? {})) { - for (const [name, method] of Object.entries(asRecord(asRecord(declared)?.methods) ?? {})) { - const node = asRecord(method); + for (const container of CONTAINERS) { + for (const [owner, declared] of Object.entries(asRecord(raw[container.holder]) ?? {})) { + const held = asRecord(asRecord(declared)?.[container.holds]); - if (!node || seen.has(name)) { - continue; + if (held && container.holder !== "classes") { + notes.push({ at: `container ${owner}`, canonical: "classes", found: container.holder }); } - seen.add(name); - actions.push(normaliseAction(name, node, className, notes)); + for (const [name, method] of Object.entries(held ?? {})) { + const node = asRecord(method); + + if (!node || seen.has(name)) { + continue; + } + + seen.add(name); + actions.push(normaliseAction(name, node, owner, notes)); + } } } return actions; } +/** + * The names a container of actions has been known by, newest last. + * + * One shape under two vocabularies rather than two shapes: a container names a contract, holds + * the values one deployment of it fills in, and holds the actions performed against it. The + * corpus renamed both halves at once — `classes.methods` became `contract_templates.actions` — + * and a document written in either is the same document. Both are read, because a wallet that + * traded one for the other would be as blind to the previous generation as it was to this one, + * and the corpus keeps several generations of the same protocol side by side. + */ +const CONTAINERS = [ + { holder: "classes", holds: "methods" }, + { holder: "contract_templates", holds: "actions" }, +] as const; + function normaliseAction( name: string, declared: Record<string, unknown>, diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index 052ee5c..535ab90 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -82,7 +82,7 @@ export function describeConstructs(manifest: NormalisedManifest): ConstructRepor } function stateOf(site: ConstructSite, key: string): ConstructState { - const construct = site.constructs[key]; + const construct = constructAt(site, key); if (!construct) { return "unrecognised"; @@ -115,6 +115,24 @@ const SHOWN: Construct = { handled: true, loadBearing: false }; const UNIMPLEMENTED: Construct = { handled: false, loadBearing: true }; const UNREAD: Construct = { handled: false, loadBearing: false }; +/** + * The two keys that belong to JSON documents rather than to this format. + * + * A comment and a pointer to a schema file can appear at any depth, decide nothing anywhere, + * and are put there by whatever wrote or edits the document. Listing them at every position + * would be nine copies of the same statement and would still be wrong at the tenth position + * someone uses one at, so they are answered once here. + */ +const DOCUMENT_CONVENTIONS: Record<string, Construct> = { + $comment: UNREAD, + $schema: UNREAD, +}; + +/** What a position says about one key, or what every position says about it. */ +function constructAt(site: ConstructSite, key: string): Construct | undefined { + return site.constructs[key] ?? DOCUMENT_CONVENTIONS[key]; +} + /** * One kind of position in a manifest, and what it may contain. * @@ -148,6 +166,12 @@ const SITES = { create_instance: UNIMPLEMENTED, description: SHOWN, inputs: READ, + // A sentence saying what this action does, written for whoever approves it, beside the + // shorter `description`. It decides nothing that gets signed. Not shown: its text + // interpolates values from the deployment and the request through a syntax no + // specification describes, and a confident sentence about the wrong amounts changes + // what a person agrees to. + intent: UNREAD, is_constructor: UNIMPLEMENTED, on_input_resolved: UNIMPLEMENTED, on_post_broadcast: UNIMPLEMENTED, @@ -190,7 +214,6 @@ const SITES = { }, manifest: { constructs: { - $comment: SHOWN, // Reserved for a signature slot that does not exist, and read by no // implementation including the reference one. attestation_version: UNREAD, @@ -198,6 +221,10 @@ const SITES = { chain: READ, classes: READ, compile_debug_symbols: READ, + // The container of a contract's actions, under the name the corpus uses now. Its + // previous name is `classes` above; the normaliser reads both and neither is + // preferred, because several generations of the same protocol coexist. + contract_templates: READ, confidential_outputs: UNIMPLEMENTED, description: SHOWN, errors: SHOWN, @@ -205,6 +232,10 @@ const SITES = { manifest_version: READ, params: READ, protocol: SHOWN, + // A block the format grew to hold what used to sit at the top level. Only the build + // mode is inside it today, lifted by the normaliser to the flat name this runtime + // already acts on. + simplicity_hl: READ, simplicity_hl_version: READ, source: UNIMPLEMENTED, utxo_types: READ, @@ -320,7 +351,7 @@ export function inspectConstructs(manifest: NormalisedManifest): ConstructFindin const site: ConstructSite = SITES[kind]; for (const key of Object.keys(node)) { - const construct = site.constructs[key]; + const construct = constructAt(site, key); if (construct?.handled) { continue; From fc1b65f1a408fa3667cd796a1872125f4c007719 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Sat, 8 Aug 2026 00:14:47 +0300 Subject: [PATCH 098/124] test(tx-manifest): count the actions nothing refuses, which an estimate got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading these protocols moved the wall; it did not remove it. Of the twenty-three actions now found, two carry nothing this wallet refuses — one in dex, one in lending_v2, none anywhere else. The estimate said sixteen. It counted the keys on an action's own node, and the blockers are mostly a level down: inside its inputs, its outputs, the witnesses within them. That is exactly where counting from a summary does not look, and it is the third time in this line of work that a count was accurate about a symptom and silent about the thing underneath it. The number is now a standing assertion rather than something anyone has to recompute, in both generations, beside the actions-found count. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- packages/tx-manifest/src/currency.test.ts | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts index 3e38bf7..ae8e509 100644 --- a/packages/tx-manifest/src/currency.test.ts +++ b/packages/tx-manifest/src/currency.test.ts @@ -131,6 +131,39 @@ describe("the protocols as their authors publish them now", () => { }); }); + // The number the whole change is about, and the one an estimate got wrong. Counting the keys + // on an action's own node says sixteen of these are clear; counting the action's whole + // subtree — its inputs, its outputs, the witnesses inside them — says two. The blockers are + // mostly a level down from where an action is named, which is exactly where an estimate does + // not look. + test("and how many of those actions carry nothing this wallet refuses", () => { + const counted = Object.fromEntries( + Object.keys(CURRENT).map((name) => { + const { manifest } = normaliseManifest(CURRENT[name]!); + const blocked = new Set( + loadBearing(inspectConstructs(manifest)).map((finding) => finding.at.split(" / ")[0]), + ); + + return [ + name, + { + found: manifest.actions.length, + unblocked: manifest.actions.filter((action) => !blocked.has(`action ${action.name}`)) + .length, + }, + ]; + }), + ); + + expect(counted).toEqual({ + dex: { found: 4, unblocked: 1 }, + last_will: { found: 4, unblocked: 0 }, + lending_v2: { found: 9, unblocked: 1 }, + lending_v3: { found: 6, unblocked: 0 }, + zeroconf: { found: 0, unblocked: 0 }, + }); + }); + // The one published protocol this wallet refuses nothing about. It declares no actions, so // nothing can be performed from it either — which is worth stating rather than letting an // empty refusal list read as success. From 4f168a0561d2e09ad5835a89f9b94575fbaaf523 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Sat, 8 Aug 2026 00:35:03 +0300 Subject: [PATCH 099/124] chore(workflow): wfctl upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two managed files and the skill installations that came with them, on their own commit so what the upgrade changed is legible rather than folded into unrelated work. The agent block gains a warning about the session brief: it grows with the number of open records and is delivered truncated once it passes what a session accepts — as a preview and a path to the rest — and the preview is the first bytes rather than the important ones, so a session opened on it starts from a fraction of the state while reading exactly like a complete one. The stop guard now says what this session demonstrated at length. Waiting on a person has to be recorded as a blocker on the owning checkpoint; prose in a message does not change what the repository reports, so the check returns. Its own text now names the count: nine consecutive messages, nine returns. Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK --- .../skills/align-project-knowledge/SKILL.md | 13 +++++++++++- .agents/skills/verify-project-work/SKILL.md | 21 +++++++++++++++++++ .claude/settings.json | 11 ++++++++++ .../skills/align-project-knowledge/SKILL.md | 13 +++++++++++- .claude/skills/verify-project-work/SKILL.md | 21 +++++++++++++++++++ .workflow/runtime/guard-stop.mjs | 18 +++++++++++----- .workflow/state.json | 2 +- AGENTS.md | 7 ++++++- skills-lock.json | 4 ++-- 9 files changed, 99 insertions(+), 11 deletions(-) diff --git a/.agents/skills/align-project-knowledge/SKILL.md b/.agents/skills/align-project-knowledge/SKILL.md index 79c2e2a..91d3867 100644 --- a/.agents/skills/align-project-knowledge/SKILL.md +++ b/.agents/skills/align-project-knowledge/SKILL.md @@ -54,7 +54,18 @@ Do not design from code and memory alone. Establish the project's current intent 10. Compare the proposed behavior with both code evidence and curated intent. 11. Treat only `knowledge/` as the default current-knowledge surface. Do not consult `raw/` or `intake/` to fill a gap. -12. Record QMD queries, graph-expanded concept paths, directly reviewed +12. Ask whether this was already decided, and search `changes` for it — the QMD + collection covering active and archived change records, selected with + `-c changes`. A decision the maintainer made is recorded verbatim in the + bundle that asked for it, and until that bundle's closure promotes it to a + page, the archive is the only place it exists. Nothing in curated knowledge + will say so, because a search of an empty decisions road truthfully finds + nothing and reads exactly like a question nobody has answered. A resolved + Wayfinder map lists them under `resolved`, one entry per answer. Cite the + promoted page when there is one and the change record when there is not, + and say which — a decision reached only through an archive is a decision the + corpus has not yet been taught. +13. Record QMD queries, graph-expanded concept paths, directly reviewed concept paths, constraints, and any conflict in the central change bundle. When a bundle already exists, run `wfctl work status <id>` and stage-specific diff --git a/.agents/skills/verify-project-work/SKILL.md b/.agents/skills/verify-project-work/SKILL.md index 9d1191e..ad97607 100644 --- a/.agents/skills/verify-project-work/SKILL.md +++ b/.agents/skills/verify-project-work/SKILL.md @@ -56,6 +56,27 @@ two-axis knowledge quality gate, validate every changed concept, and list exact concepts under `knowledge_promotion`. Otherwise record a concrete no-update reason. +Account for what this work decided before it closes. Every answer the maintainer +gave is recorded verbatim in this bundle, and closure is the last moment it can +reach a page rather than an archive: + +```sh +wfctl work decisions <id> +wfctl work decisions <id> --what "<the decision>" --said "<where they said it>" \ + --promoted knowledge/decisions/<slug>.md +``` + +`--folded <concept>` when an existing page already carries it, `--not-durable` +with a reason when it settles nothing beyond this work, and `--none "<why>"` when +the whole bundle settled nothing. A resolved Wayfinder map is checked against, so +an answer it recorded cannot archive unaccounted for. + +Nothing here asks the maintainer anything. They decided already; this records +where their answer now lives. What does reach them is one confirmation, not one +per decision: the pages are drafted from their own words, and the question is +whether the rendering is faithful — asked once, with named exceptions, as +`maintainer-review` requires of confirming written records. + A concept promoted from a bundle in a project with no reconstructed baseline carries the same shape as one established by whole-project reading and a much narrower footing: it was derived from whatever this task happened to touch. Say diff --git a/.claude/settings.json b/.claude/settings.json index a17730b..653a4e4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -21,6 +21,17 @@ } ] } + ], + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "wfctl brief --hook" + } + ] + } ] } } diff --git a/.claude/skills/align-project-knowledge/SKILL.md b/.claude/skills/align-project-knowledge/SKILL.md index 79c2e2a..91d3867 100644 --- a/.claude/skills/align-project-knowledge/SKILL.md +++ b/.claude/skills/align-project-knowledge/SKILL.md @@ -54,7 +54,18 @@ Do not design from code and memory alone. Establish the project's current intent 10. Compare the proposed behavior with both code evidence and curated intent. 11. Treat only `knowledge/` as the default current-knowledge surface. Do not consult `raw/` or `intake/` to fill a gap. -12. Record QMD queries, graph-expanded concept paths, directly reviewed +12. Ask whether this was already decided, and search `changes` for it — the QMD + collection covering active and archived change records, selected with + `-c changes`. A decision the maintainer made is recorded verbatim in the + bundle that asked for it, and until that bundle's closure promotes it to a + page, the archive is the only place it exists. Nothing in curated knowledge + will say so, because a search of an empty decisions road truthfully finds + nothing and reads exactly like a question nobody has answered. A resolved + Wayfinder map lists them under `resolved`, one entry per answer. Cite the + promoted page when there is one and the change record when there is not, + and say which — a decision reached only through an archive is a decision the + corpus has not yet been taught. +13. Record QMD queries, graph-expanded concept paths, directly reviewed concept paths, constraints, and any conflict in the central change bundle. When a bundle already exists, run `wfctl work status <id>` and stage-specific diff --git a/.claude/skills/verify-project-work/SKILL.md b/.claude/skills/verify-project-work/SKILL.md index 9d1191e..ad97607 100644 --- a/.claude/skills/verify-project-work/SKILL.md +++ b/.claude/skills/verify-project-work/SKILL.md @@ -56,6 +56,27 @@ two-axis knowledge quality gate, validate every changed concept, and list exact concepts under `knowledge_promotion`. Otherwise record a concrete no-update reason. +Account for what this work decided before it closes. Every answer the maintainer +gave is recorded verbatim in this bundle, and closure is the last moment it can +reach a page rather than an archive: + +```sh +wfctl work decisions <id> +wfctl work decisions <id> --what "<the decision>" --said "<where they said it>" \ + --promoted knowledge/decisions/<slug>.md +``` + +`--folded <concept>` when an existing page already carries it, `--not-durable` +with a reason when it settles nothing beyond this work, and `--none "<why>"` when +the whole bundle settled nothing. A resolved Wayfinder map is checked against, so +an answer it recorded cannot archive unaccounted for. + +Nothing here asks the maintainer anything. They decided already; this records +where their answer now lives. What does reach them is one confirmation, not one +per decision: the pages are drafted from their own words, and the question is +whether the rendering is faithful — asked once, with named exceptions, as +`maintainer-review` requires of confirming written records. + A concept promoted from a bundle in a project with no reconstructed baseline carries the same shape as one established by whole-project reading and a much narrower footing: it was derived from whatever this task happened to touch. Say diff --git a/.workflow/runtime/guard-stop.mjs b/.workflow/runtime/guard-stop.mjs index 92afa1a..5944b9b 100644 --- a/.workflow/runtime/guard-stop.mjs +++ b/.workflow/runtime/guard-stop.mjs @@ -236,17 +236,25 @@ function reason(message, awaiting) { "not, you have not finished: take the next action you can take alone. That", "includes an action you named, and equally one you never mentioned.", "", - "If you are waiting on them, name in one line what you need and end. \"The", - "work continues by itself\" is not that line — nothing continues once the", - "turn is over.", + "If you are waiting on them, say so where the repository can see it, not only", + "in your message. Record it as a blocker on the owning checkpoint:", + "", + " wfctl work checkpoint <id> --actor ... --blockers \"<what you need from them>\"", + "", + "A checkpoint that names a blocker is held for the maintainer, and this check", + "goes quiet. Prose does not do that. An agent already blocked on a person", + "wrote what it needed in nine consecutive messages and was returned nine", + "times, because nothing it wrote changed what the repository reported.", "", "Either way, run `wfctl resumable` before you end. It answers whether", "stopping now would lose anything, and a non-zero exit means refresh the", "checkpoint or commit first rather than reporting the problem onward.", "", "This check returns while each turn moves the repository and releases on the", - "first turn that does not. Do not acknowledge it, agree with it, explain", - "yourself, or answer with an empty turn.", + "first turn that does not — so finding unrelated work to do keeps it coming", + "back. If what you are missing is a person, the line above ends it; more work", + "does not. Do not acknowledge this check, agree with it, explain yourself, or", + "answer with an empty turn.", ].join("\n"); } diff --git a/.workflow/state.json b/.workflow/state.json index 64ffcd0..ebce412 100644 --- a/.workflow/state.json +++ b/.workflow/state.json @@ -46,7 +46,7 @@ "sha256": "0bb95f5bc34cb2ee9c3c453a171cd36f60d8c7d4eeb283ea07e8ad91d5b3ef93" }, ".workflow/runtime/guard-stop.mjs": { - "sha256": "1c83e1c15e047609016ae24c7966d9167856d64fd7c92b95690bd1c848dcaab0" + "sha256": "726c6a1cc4865ef9c7d86f3e85dc26341b8738960a456a16b35806b69ffca8aa" }, ".workflow/runtime/idle-guard.sh": { "sha256": "8a132664ff43cc732e4da14b33e7b72f89e5facbb26169b3135eac2c64390da2" diff --git a/AGENTS.md b/AGENTS.md index 10d4920..105f774 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,12 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und about it, and reaches the next session through the brief. Anything you intend to "come back to" and leave only in prose is lost with the context holding it. - Run `wfctl brief --json` before anything else in a session, unless a session - brief was already delivered as context, in which case use that one. It is the + brief was already delivered as context, in which case use that one — after + checking it arrived whole. A brief grows with the number of open records and + is delivered truncated once it passes what a session accepts, as a preview and + a path to the rest. Read that path before using it. The preview is the first + bytes rather than the important ones, and a session opened on it starts from a + fraction of the state while reading exactly like a complete one. It is the authoritative current state of this repository: signals are observed facts and capabilities are derived from them. Do not rediscover that state by scanning records, and do not read the list back to the maintainer. Every open record diff --git a/skills-lock.json b/skills-lock.json index 52be793..2ce6c6a 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -4,7 +4,7 @@ "align-project-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "99a3e86766230d7f82c2370d3e31ec2437b68983e1cdd5c84b6e4397634debac" + "computedHash": "4b16b699280863a9a8cb0d0a0fda3abf77ab150024920293b79b29c932fc7f42" }, "analyze-with-graphify": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", @@ -74,7 +74,7 @@ "verify-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "39f6891a942dab859ff17efca8e7d953037530153acb183f61151ec855c087e6" + "computedHash": "beb0ed2cdac8ebaa4fd5b3af706e2c3c0cc6df628051ce3f837329eeb0d7165e" } } } From ec21d463da969387303b9dfbcc8a5ff69bfb2fdd Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Sun, 9 Aug 2026 18:53:11 +0300 Subject: [PATCH 100/124] =?UTF-8?q?chore(workflow):=20wfctl=20upgrade=20?= =?UTF-8?q?=E2=80=94=20the=20completion=20render,=20the=20stop=20guard,=20?= =?UTF-8?q?and=20the=20verification=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its own files, in their own commit, so what the upgrade changed stays separable from unrelated work. 12 tracked files. The completion decision is now rendered by `wfctl work ask <id> --stage completion` instead of composed by hand, in four parts: what the work does now, what it still does not do, what closing it takes on, and what the project now says that it did not. The verification skill drops the gate requirements it used to restate and keeps the half no gate can see. The stop guard no longer treats an unclaimed frontier as work in the agent's hands, so a bundle whose issues are all waiting stops arming it. Its message names targets rather than prohibitions, and it now records each block for later evidence. The new rules and agent block reach a session only at its start; restart before relying on them. Claude-Session: https://claude.ai/code/session_014mF7nZbX5JsCFcdT99Q8XD --- .agents/skills/verify-project-work/SKILL.md | 215 ++++++++++---------- .claude/rules/execution-continuity.md | 14 +- .claude/rules/maintainer-review.md | 116 ++++++----- .claude/skills/verify-project-work/SKILL.md | 215 ++++++++++---------- .workflow/rules/execution-continuity.md | 14 +- .workflow/rules/maintainer-review.md | 116 ++++++----- .workflow/runtime/guard-background-bash.mjs | 4 +- .workflow/runtime/guard-stop.mjs | 79 +++++-- .workflow/runtime/idle-guard.sh | 8 +- .workflow/state.json | 14 +- AGENTS.md | 33 +-- skills-lock.json | 2 +- 12 files changed, 464 insertions(+), 366 deletions(-) diff --git a/.agents/skills/verify-project-work/SKILL.md b/.agents/skills/verify-project-work/SKILL.md index ad97607..91cc097 100644 --- a/.agents/skills/verify-project-work/SKILL.md +++ b/.agents/skills/verify-project-work/SKILL.md @@ -1,131 +1,120 @@ --- name: verify-project-work -description: Verify a significant project change against every file in its central bundle, every stable acceptance ID, and every exact bound source revision without hiding gaps. Use before claiming a bounded change, slice, multi-repository delivery, project-only decision, or architecture change complete; before knowledge promotion; or when auditing an implementation against its approved contract. +description: Verify a change against its contract and its real production path before anyone calls it done. Use when claiming work complete, before promoting what it established into knowledge, or when auditing an implementation against the framing that was approved. --- # Verify Project Work -Completion is a claim supported by complete accounting and fresh semantic -evidence. Structural green output is never proof by itself. - -Read [the completion gate](references/completion-gate.md) before recording final -receipts. - -## Account for the whole bundle - -1. Run `wfctl work status <id>` and stop on any checkout, branch, worktree, or - binding mismatch. -2. Run `wfctl work context <id> --stage review`. -3. Read every required file completely, including long issue tails and every - artifact that informs the result. Mark only a genuinely irrelevant - supporting artifact `irrelevant`, with a reason. -4. After each read, record `wfctl work review file <id> <path>`. Re-read and - refresh a receipt whenever the file changes. -5. Run `wfctl work review status <id>` and require zero unseen, - changed-after-review, or invalid files. - -## Review on independent axes - -**Contract axis:** map every stable acceptance ID to non-dropped issues, -production behavior, direct evidence, and a verification receipt. Find missing -requirements, partial behavior, scope creep, and implementation that appears -present but contradicts the contract. - -**Engineering axis:** inspect the real diff and production path in every exact -code root. Invoke Graphify-first analysis, then open actual source, callers, -boundaries, state, errors, and consumers. Check project standards, architecture, -security, operations, and maintainability independently of whether the spec was -followed. - -Run focused behavior checks and the broader relevant test, build, type, lint, -and runtime checks. Expected values must come from the contract or an -independent authority, not the implementation. Look adversarially for disabled -paths, placeholders, mocks, fixtures, temporary compatibility code, unhandled -branches, and silently deferred work. - -Record one exact repository/revision/worktree receipt per bound leaf. Require a -clean commit containing the reviewed implementation; obtain normal maintainer -authorization before committing. For project-only work, verify decisions, -knowledge, and links without inventing code evidence. - -## Promote and close - -Decide whether verified durable truth changed. If yes, route product behavior -and engineering realization through their separate curation skills, run the -two-axis knowledge quality gate, validate every changed concept, and list exact -concepts under `knowledge_promotion`. Otherwise record a concrete no-update -reason. - -Account for what this work decided before it closes. Every answer the maintainer -gave is recorded verbatim in this bundle, and closure is the last moment it can -reach a page rather than an archive: +A green build proves the build is green. Completion is a claim about what the +product now does, and it holds only because someone looked. -```sh -wfctl work decisions <id> -wfctl work decisions <id> --what "<the decision>" --said "<where they said it>" \ - --promoted knowledge/decisions/<slug>.md -``` +The tool refuses an incomplete accounting on its own: a stale receipt, an open +issue, a missing approval, a dirty checkout, an unaccounted decision. It cannot +tell reading from recording, a check that proves something from one that merely +passes, or a criterion nobody exercised from one that failed. Those are yours, +and they are what this skill is for. -`--folded <concept>` when an existing page already carries it, `--not-durable` -with a reason when it settles nothing beyond this work, and `--none "<why>"` when -the whole bundle settled nothing. A resolved Wayfinder map is checked against, so -an answer it recorded cannot archive unaccounted for. - -Nothing here asks the maintainer anything. They decided already; this records -where their answer now lives. What does reach them is one confirmation, not one -per decision: the pages are drafted from their own words, and the question is -whether the rendering is faithful — asked once, with named exceptions, as -`maintainer-review` requires of confirming written records. - -A concept promoted from a bundle in a project with no reconstructed baseline -carries the same shape as one established by whole-project reading and a much -narrower footing: it was derived from whatever this task happened to touch. Say -so in its `maintainer-decision` source — name the bundle and state that no -reconstruction has covered this subject — so a later baseline knows to re-derive -it rather than treat it as already settled. Promote it anyway: knowledge grown -from real work is better than none, and the cost of the shortcut is only hidden -when nobody writes it down. - -Present acceptance results, engineering findings, checks, deviations, risks, -and knowledge delta as one completion review packet. It is maintainer-facing: -the reader test in `maintainer-review` applies, so acceptance is named by what -was asked for rather than by criterion id, and a deviation by what changed for -the product rather than by which file moved. Record the maintainer's -explicit decision through the approval command; never write the receipt by -hand: +Read [the completion gate](references/completion-gate.md) when a refusal names a +requirement you have not met, or when deciding what a partial closure must say. -```sh -wfctl work approve <id> --stage completion \ - --by human:<maintainer-id> \ - --note "<what the maintainer accepted>" -``` +## Read what the review stage names -Record what they answered with `--attested "<their words>" --session -"<where>"`; a typed confirmation and a `--token` matching `WFCTL_APPROVAL_TOKEN` -remain for a stronger record. `wfctl work verify` rejects a receipt with no -matching approval record. Finish all semantic edits to `change.md`, then refresh its -checkpoint in review stage **before** recording the final hash receipt: +`wfctl work context <id> --stage review` lists what must be accounted for, and +`wfctl work review file` records each one at its current hash. -```sh -wfctl work checkpoint <id> \ - --actor "agent:<identity>" \ - --stage review \ - --state "Final verification and maintainer decision are recorded." \ - --last "Reconciled acceptance, implementation, and knowledge promotion." \ - --next "Re-read changed bundle files, refresh their receipts, and run the completion gate." -``` +A receipt proves accounting. Comprehension has no receipt, which is why reading +is the step and recording is the residue. Read each file to its end: a long issue +tail is where deferred work gets written down, and it is the part a skim reaches +last. Mark a supporting artifact `irrelevant` only when you can say what makes it +irrelevant to this result. + +A receipt binds to the bytes it was taken over, so any file the review itself +edits comes back as changed-after-review and needs reading again. Expect that of +`change.md` in particular: the review is what changes it. + +## Review the contract + +Take each thing that was asked for and find three things: the production +behaviour that delivers it, the evidence that it does, and the path a person +using or operating the product reaches it by. + +Expect the gaps to be quiet. A requirement nothing implements. A behaviour that +stops halfway. Work that arrived unasked. An implementation that looks present +and does the opposite of what was agreed. + +Expected values come from the contract or an independent authority. An expected +value read off the implementation confirms the implementation to itself. + +## Review the engineering + +Invoke Graphify-first analysis, then open the real diff and the production path +in every bound code root: source, callers, boundaries, state, errors, consumers. +Judge project standards, architecture, security, operations and maintainability +on their own, whether or not the spec was followed. Run the focused behaviour +checks and the broader test, build, type, lint and runtime checks. + +Hunt what a passing suite hides — a disabled path, a placeholder, a mock standing +in for the thing, a fixture doing the work, temporary compatibility code, an +unhandled branch, work quietly deferred. `evidence-first` says what each of those +proves and what it does not; here the point is that a review which never went +looking will not find one. + +Ask before you commit. Closure requires a clean checkout whose recorded commit +contains the reviewed implementation, and the gate cannot see whether anyone +agreed to the commit that made it clean. + +For project-only work, verify decisions, knowledge and links, and let code +evidence stay absent rather than invented. + +## Decide what the project now knows + +Decide whether verified durable truth changed. When it did, route product +behaviour and engineering realization through their separate curation skills, run +the two-axis quality gate, and validate every changed concept. When it did not, +say concretely why. + +A concept promoted from a bundle in a project with no reconstructed baseline has +the same shape as one established by whole-project reading and a far narrower +footing: it came from whatever this task happened to touch. Say so in its +`maintainer-decision` source — name the bundle, state that no reconstruction has +covered this subject — so a later baseline re-derives it instead of trusting it. +Promote it anyway. Knowledge grown from real work beats none, and the shortcut +costs something only while it stays unwritten. + +Account for every answer the maintainer gave with `wfctl work decisions <id>`. +Closure is the last moment an answer can reach a page instead of an archive, and +a resolved Wayfinder map is the checklist the accounting is held against. + +This asks them nothing — they decided already. What reaches them is one +confirmation that the drafted pages are faithful to their own words: once, with +named exceptions, as `maintainer-review` requires of confirming written records. + +## Put the decision, then close + +Render it with `wfctl work ask <id> --stage completion`, which carries the four +things accepting one fixes and reads them from the record. + +The render is only as honest as what it reads. An unresolved risk nobody wrote +down stays invisible, and a criterion left `pending` because nobody checked reads +exactly like one that failed. Repair the record and render again; a packet edited +by hand is composed again, and composed is what put file paths and criterion ids +in front of them. + +Record their decision with `wfctl work approve <id> --stage completion`, passing +their own answer through `--attested`. A hand-written receipt fails verification. -Re-read `change.md` completely after that command and refresh its receipt, plus -every other file changed by the review. Require a current checkpoint and zero -unseen, changed-after-review, or invalid files. Then run: +Then finish in this order, because each step invalidates the one before it. +Every semantic edit to `change.md` first. Then `wfctl work checkpoint <id> +--stage review`, before the final hash receipt — a checkpoint edit changes the +file's hash, so a receipt taken ahead of it is stale on arrival. Then re-read +`change.md` and everything else the review changed, and refresh those receipts. +Then: ```sh wfctl work verify <id> wfctl work close <id> --outcome completed|partial|abandoned ``` -Use the honest outcome. Completed closure fails on open issues or claims, -unresolved Wayfinder state, acceptance gaps, stale file receipts, dirty or -mismatched source revisions, a stale checkpoint, missing evidence, or -incomplete promotion. Closing the bundle makes its checkpoint terminal; do not -create a capture for this completed session state. +Use the honest outcome. `partial` and `abandoned` are results, and a `completed` +that had to be argued for is the one worth doubting. Closing makes the bundle's +checkpoint terminal; the session state it holds is finished rather than captured. diff --git a/.claude/rules/execution-continuity.md b/.claude/rules/execution-continuity.md index 202da1e..e87571c 100644 --- a/.claude/rules/execution-continuity.md +++ b/.claude/rules/execution-continuity.md @@ -34,7 +34,19 @@ larger half announces nothing — "the work continues by itself", "the rest can wait for the next boundary", a status report that names no blocker — and parks just as completely, because nothing continues once the turn is over. If you are not waiting on the maintainer, take the next action you can take alone. If you -are, name in one line what you need from them. +are, name what you need from them **and record it as a blocker on the owning +checkpoint**. Only that changes what the repository reports, so only that stops +you being returned to a turn whose one missing piece is a person. + +**Never wait for a background command by spinning.** A command that outruns its +foreground limit is moved to the background and announces its own completion; +waiting for it is not your job and doing so costs more than it saves. A loop +that polls a file without pausing consumes a whole core, and it takes that core +from the build it is waiting for — a Rust suite was watched this way twice in +one session, for five hundred seconds each, against a compile competing for the +same processor, and the second watch timed out having learned nothing. If you +genuinely must wait on something the host does not announce, pause between +checks rather than spinning, and prefer doing unrelated accepted work. **A discovery is not a stop.** Material that contradicts what was accepted is an entry in the discovery ledger, and the work continues. Stop only when the diff --git a/.claude/rules/maintainer-review.md b/.claude/rules/maintainer-review.md index 3f3edd3..7f6ba2a 100644 --- a/.claude/rules/maintainer-review.md +++ b/.claude/rules/maintainer-review.md @@ -29,36 +29,58 @@ consequence found while reading — a rule in one repository that makes the proposed route impossible there is a decision, and it is put to them as what the product must do instead, never as the file it was found in. -Present a compact review packet containing the exact decision, evidence, -conflicts, recommendation, and requested response. Record approve, correct, or -defer. Ask one focused question at a time. Silence is not approval. - -## Write the decision for the product owner - -The maintainer decides what the product should be. They are not reading to -check the investigation, and they do not hold the file layout, the type names, -or yesterday's discovery numbering in their head. A packet written as if they -do forces them to reconstruct the product meaning out of engineering artifacts -before they can answer, and the usual outcome is not a wrong decision but no -decision at all. - -Order the packet so the decision is answerable from the top: - -1. **What happens today**, in the words a person using or operating the product - would use. No file paths, no symbol names, no identifiers the product's own - language does not contain. -2. **What is in doubt**, stated as a product question rather than an - implementation observation. -3. **What each answer means** — what gets recorded, what changes, what does not. - Reconstruction never edits source, so say plainly that no answer here fixes - anything by itself. -4. **The recommendation**, one option named, with its cost. -5. **The evidence**, last and clearly separable, addressed by claim or discovery - id for anyone who wants to audit it. - -Detail is not the problem and must not be dropped; its position is. Depth that -arrives before the question buries it, and the same depth below a stated -question supports it. +Record approve, correct, or defer. Silence is not approval. + +## Write for someone who was not watching + +The maintainer did not see the tool calls, the gate that refused, or the file +you fixed on the way. They hold no identifier you generated and no count from +your last turn. Every message is read cold, by a person deciding about their +product, and one that catches them up first buries what it came to say. + +A message carries three things in this order, and stops: + +1. **What is true now** that was not true before. One or two lines. +2. **What you need from them**, if anything. One question, one recommendation. +3. **What happens next without them.** One line. + +Everything else goes in the record, which already has a place for each kind of +it: the discovery ledger for what you learned, the checkpoint for where the work +stands, review receipts for what you read, the blocker for what you are waiting +on. Sending it instead is not thoroughness. It moves the reading onto the person +least able to do it, and the record still ends up empty. + +The message is finished when cutting any remaining sentence would change what +the maintainer does next. Cut one and check. If nothing changes, it was written +for you. + +A structured document is the common way to fail this, because it looks like +care. Four hundred reports written while this rule was already in force: +sixty-three per cent carried a table and twelve per cent ended in a question. A +table compares things the reader already cares about. Reached for before there +is a question, it is a log with borders. Write the three lines first, and add a +table only when the answer turns on a comparison. + +**Sentences.** Use the active voice. Give one idea per sentence and keep it to +twenty words. Use simple past, present, and future. Use the same word for the +same thing every time; a synonym reads as a second thing. Drop idioms, slang, +and internal vocabulary. Keep paragraphs to six sentences. + +**Keep technical items exact.** A path, a symbol, a version, a price, or a count +is reproduced character for character or left out. Never paraphrase one into +approximate prose: "about five thousand" and "the pricing worker" cannot be +checked, and being unable to check it is the cost the shortening was supposed to +avoid. What decides whether it appears at all is the reader test below. + +## Ask one thing, and make it cheap to answer + +Give the answer a shape they can hit. Name the options, or say what a bare yes +changes. A question with no stub gets answered with a paragraph you then have to +interpret, which is a second decision you made on their behalf. + +Say plainly that "I do not know" is an answer you can record. A recorded gap is +worth more than a preference guessed from a polite reply and then treated as +settled. ## Re-establish the subject before asking about it @@ -88,21 +110,14 @@ about each claim. Several claims about one capability are one question; one claim touching several capabilities is still one question, about the one thing that has to be decided. -If the subject cannot be described without file paths or symbol names, that is a -finding to record — the capability has no product-level description yet — and -not a licence to send the identifiers instead. - Translate rather than cite. A term that exists only in the implementation gets replaced by what it does for the product, with the original in parentheses at -most once. If a decision genuinely cannot be stated without engineering -vocabulary, that is a finding rather than a licence: the capability has no -product-level description yet, and recording that gap is part of the answer. +most once. A subject that cannot be described at all without file paths or +symbol names is a finding to record — the capability has no product-level +description yet — and never a licence to send the identifiers instead. ## The line, and how to tell which side you are on -Everything this workflow produces is written for one of two readers, and the two -are not styles of the same text. They are different texts. - | | Written for the record | Written for the maintainer | | --- | --- | --- | | Reader | The next agent, the compiler, an auditor | A person deciding about their product | @@ -117,11 +132,10 @@ Apply one test to every sentence before it reaches the maintainer: If yes, it is an address, whatever form it takes — a file, a symbol, a record id, a ledger code, a commit, a section number, a milestone or tier code, a schema value printed as a category, or a term that exists only inside the -implementation. The test does not depend on the shape of the string or on the -language anything is written in, which is why it is the test and not a list. +implementation. It is a test rather than a list because it reads the reader, not +the shape of the string or the language it is written in. -Two failure modes, and the second is the one that follows from over-correcting -the first: +Two failure modes, and over-correcting the first produces the second: - **Addressed prose.** "The check in the catalog store was narrowed because the gate validates on publish." Every noun is something to look up. @@ -179,11 +193,15 @@ it, and a release is never inferred: not from a truthful answer to an unrelated question, and not from the condition that held it having cleared. The last time one was inferred, six commits landed in three source repositories. -Render the framing with `wfctl work ask <id>` rather than composing one. It -carries the four things approval fixes — what gets done, what deliberately does -not, what will make it finished, and in what order — and nothing else from a -record written for an agent. A section still holding the shipped template's own -words is reported as unwritten rather than read out as scope. +Render both gates with `wfctl work ask <id> [--stage completion]` rather than +composing one. Each carries the four things its decision fixes — a framing: +what gets done, what deliberately does not, what will make it finished, and in +what order; a completion: what the work does now, what it still does not do, +what closing it takes on, and what the project now says that it did not — and +nothing else from a record written for an agent. A section still holding the +shipped template's own words is reported as unwritten rather than read out as +scope. A render is only as honest as the record behind it: repair the record +rather than the packet, because a packet edited by hand is composed again. For significant work, record framing and completion decisions with `wfctl work approve <id> --stage framing|completion --by human:<maintainer-id>`. It writes diff --git a/.claude/skills/verify-project-work/SKILL.md b/.claude/skills/verify-project-work/SKILL.md index ad97607..91cc097 100644 --- a/.claude/skills/verify-project-work/SKILL.md +++ b/.claude/skills/verify-project-work/SKILL.md @@ -1,131 +1,120 @@ --- name: verify-project-work -description: Verify a significant project change against every file in its central bundle, every stable acceptance ID, and every exact bound source revision without hiding gaps. Use before claiming a bounded change, slice, multi-repository delivery, project-only decision, or architecture change complete; before knowledge promotion; or when auditing an implementation against its approved contract. +description: Verify a change against its contract and its real production path before anyone calls it done. Use when claiming work complete, before promoting what it established into knowledge, or when auditing an implementation against the framing that was approved. --- # Verify Project Work -Completion is a claim supported by complete accounting and fresh semantic -evidence. Structural green output is never proof by itself. - -Read [the completion gate](references/completion-gate.md) before recording final -receipts. - -## Account for the whole bundle - -1. Run `wfctl work status <id>` and stop on any checkout, branch, worktree, or - binding mismatch. -2. Run `wfctl work context <id> --stage review`. -3. Read every required file completely, including long issue tails and every - artifact that informs the result. Mark only a genuinely irrelevant - supporting artifact `irrelevant`, with a reason. -4. After each read, record `wfctl work review file <id> <path>`. Re-read and - refresh a receipt whenever the file changes. -5. Run `wfctl work review status <id>` and require zero unseen, - changed-after-review, or invalid files. - -## Review on independent axes - -**Contract axis:** map every stable acceptance ID to non-dropped issues, -production behavior, direct evidence, and a verification receipt. Find missing -requirements, partial behavior, scope creep, and implementation that appears -present but contradicts the contract. - -**Engineering axis:** inspect the real diff and production path in every exact -code root. Invoke Graphify-first analysis, then open actual source, callers, -boundaries, state, errors, and consumers. Check project standards, architecture, -security, operations, and maintainability independently of whether the spec was -followed. - -Run focused behavior checks and the broader relevant test, build, type, lint, -and runtime checks. Expected values must come from the contract or an -independent authority, not the implementation. Look adversarially for disabled -paths, placeholders, mocks, fixtures, temporary compatibility code, unhandled -branches, and silently deferred work. - -Record one exact repository/revision/worktree receipt per bound leaf. Require a -clean commit containing the reviewed implementation; obtain normal maintainer -authorization before committing. For project-only work, verify decisions, -knowledge, and links without inventing code evidence. - -## Promote and close - -Decide whether verified durable truth changed. If yes, route product behavior -and engineering realization through their separate curation skills, run the -two-axis knowledge quality gate, validate every changed concept, and list exact -concepts under `knowledge_promotion`. Otherwise record a concrete no-update -reason. - -Account for what this work decided before it closes. Every answer the maintainer -gave is recorded verbatim in this bundle, and closure is the last moment it can -reach a page rather than an archive: +A green build proves the build is green. Completion is a claim about what the +product now does, and it holds only because someone looked. -```sh -wfctl work decisions <id> -wfctl work decisions <id> --what "<the decision>" --said "<where they said it>" \ - --promoted knowledge/decisions/<slug>.md -``` +The tool refuses an incomplete accounting on its own: a stale receipt, an open +issue, a missing approval, a dirty checkout, an unaccounted decision. It cannot +tell reading from recording, a check that proves something from one that merely +passes, or a criterion nobody exercised from one that failed. Those are yours, +and they are what this skill is for. -`--folded <concept>` when an existing page already carries it, `--not-durable` -with a reason when it settles nothing beyond this work, and `--none "<why>"` when -the whole bundle settled nothing. A resolved Wayfinder map is checked against, so -an answer it recorded cannot archive unaccounted for. - -Nothing here asks the maintainer anything. They decided already; this records -where their answer now lives. What does reach them is one confirmation, not one -per decision: the pages are drafted from their own words, and the question is -whether the rendering is faithful — asked once, with named exceptions, as -`maintainer-review` requires of confirming written records. - -A concept promoted from a bundle in a project with no reconstructed baseline -carries the same shape as one established by whole-project reading and a much -narrower footing: it was derived from whatever this task happened to touch. Say -so in its `maintainer-decision` source — name the bundle and state that no -reconstruction has covered this subject — so a later baseline knows to re-derive -it rather than treat it as already settled. Promote it anyway: knowledge grown -from real work is better than none, and the cost of the shortcut is only hidden -when nobody writes it down. - -Present acceptance results, engineering findings, checks, deviations, risks, -and knowledge delta as one completion review packet. It is maintainer-facing: -the reader test in `maintainer-review` applies, so acceptance is named by what -was asked for rather than by criterion id, and a deviation by what changed for -the product rather than by which file moved. Record the maintainer's -explicit decision through the approval command; never write the receipt by -hand: +Read [the completion gate](references/completion-gate.md) when a refusal names a +requirement you have not met, or when deciding what a partial closure must say. -```sh -wfctl work approve <id> --stage completion \ - --by human:<maintainer-id> \ - --note "<what the maintainer accepted>" -``` +## Read what the review stage names -Record what they answered with `--attested "<their words>" --session -"<where>"`; a typed confirmation and a `--token` matching `WFCTL_APPROVAL_TOKEN` -remain for a stronger record. `wfctl work verify` rejects a receipt with no -matching approval record. Finish all semantic edits to `change.md`, then refresh its -checkpoint in review stage **before** recording the final hash receipt: +`wfctl work context <id> --stage review` lists what must be accounted for, and +`wfctl work review file` records each one at its current hash. -```sh -wfctl work checkpoint <id> \ - --actor "agent:<identity>" \ - --stage review \ - --state "Final verification and maintainer decision are recorded." \ - --last "Reconciled acceptance, implementation, and knowledge promotion." \ - --next "Re-read changed bundle files, refresh their receipts, and run the completion gate." -``` +A receipt proves accounting. Comprehension has no receipt, which is why reading +is the step and recording is the residue. Read each file to its end: a long issue +tail is where deferred work gets written down, and it is the part a skim reaches +last. Mark a supporting artifact `irrelevant` only when you can say what makes it +irrelevant to this result. + +A receipt binds to the bytes it was taken over, so any file the review itself +edits comes back as changed-after-review and needs reading again. Expect that of +`change.md` in particular: the review is what changes it. + +## Review the contract + +Take each thing that was asked for and find three things: the production +behaviour that delivers it, the evidence that it does, and the path a person +using or operating the product reaches it by. + +Expect the gaps to be quiet. A requirement nothing implements. A behaviour that +stops halfway. Work that arrived unasked. An implementation that looks present +and does the opposite of what was agreed. + +Expected values come from the contract or an independent authority. An expected +value read off the implementation confirms the implementation to itself. + +## Review the engineering + +Invoke Graphify-first analysis, then open the real diff and the production path +in every bound code root: source, callers, boundaries, state, errors, consumers. +Judge project standards, architecture, security, operations and maintainability +on their own, whether or not the spec was followed. Run the focused behaviour +checks and the broader test, build, type, lint and runtime checks. + +Hunt what a passing suite hides — a disabled path, a placeholder, a mock standing +in for the thing, a fixture doing the work, temporary compatibility code, an +unhandled branch, work quietly deferred. `evidence-first` says what each of those +proves and what it does not; here the point is that a review which never went +looking will not find one. + +Ask before you commit. Closure requires a clean checkout whose recorded commit +contains the reviewed implementation, and the gate cannot see whether anyone +agreed to the commit that made it clean. + +For project-only work, verify decisions, knowledge and links, and let code +evidence stay absent rather than invented. + +## Decide what the project now knows + +Decide whether verified durable truth changed. When it did, route product +behaviour and engineering realization through their separate curation skills, run +the two-axis quality gate, and validate every changed concept. When it did not, +say concretely why. + +A concept promoted from a bundle in a project with no reconstructed baseline has +the same shape as one established by whole-project reading and a far narrower +footing: it came from whatever this task happened to touch. Say so in its +`maintainer-decision` source — name the bundle, state that no reconstruction has +covered this subject — so a later baseline re-derives it instead of trusting it. +Promote it anyway. Knowledge grown from real work beats none, and the shortcut +costs something only while it stays unwritten. + +Account for every answer the maintainer gave with `wfctl work decisions <id>`. +Closure is the last moment an answer can reach a page instead of an archive, and +a resolved Wayfinder map is the checklist the accounting is held against. + +This asks them nothing — they decided already. What reaches them is one +confirmation that the drafted pages are faithful to their own words: once, with +named exceptions, as `maintainer-review` requires of confirming written records. + +## Put the decision, then close + +Render it with `wfctl work ask <id> --stage completion`, which carries the four +things accepting one fixes and reads them from the record. + +The render is only as honest as what it reads. An unresolved risk nobody wrote +down stays invisible, and a criterion left `pending` because nobody checked reads +exactly like one that failed. Repair the record and render again; a packet edited +by hand is composed again, and composed is what put file paths and criterion ids +in front of them. + +Record their decision with `wfctl work approve <id> --stage completion`, passing +their own answer through `--attested`. A hand-written receipt fails verification. -Re-read `change.md` completely after that command and refresh its receipt, plus -every other file changed by the review. Require a current checkpoint and zero -unseen, changed-after-review, or invalid files. Then run: +Then finish in this order, because each step invalidates the one before it. +Every semantic edit to `change.md` first. Then `wfctl work checkpoint <id> +--stage review`, before the final hash receipt — a checkpoint edit changes the +file's hash, so a receipt taken ahead of it is stale on arrival. Then re-read +`change.md` and everything else the review changed, and refresh those receipts. +Then: ```sh wfctl work verify <id> wfctl work close <id> --outcome completed|partial|abandoned ``` -Use the honest outcome. Completed closure fails on open issues or claims, -unresolved Wayfinder state, acceptance gaps, stale file receipts, dirty or -mismatched source revisions, a stale checkpoint, missing evidence, or -incomplete promotion. Closing the bundle makes its checkpoint terminal; do not -create a capture for this completed session state. +Use the honest outcome. `partial` and `abandoned` are results, and a `completed` +that had to be argued for is the one worth doubting. Closing makes the bundle's +checkpoint terminal; the session state it holds is finished rather than captured. diff --git a/.workflow/rules/execution-continuity.md b/.workflow/rules/execution-continuity.md index 202da1e..e87571c 100644 --- a/.workflow/rules/execution-continuity.md +++ b/.workflow/rules/execution-continuity.md @@ -34,7 +34,19 @@ larger half announces nothing — "the work continues by itself", "the rest can wait for the next boundary", a status report that names no blocker — and parks just as completely, because nothing continues once the turn is over. If you are not waiting on the maintainer, take the next action you can take alone. If you -are, name in one line what you need from them. +are, name what you need from them **and record it as a blocker on the owning +checkpoint**. Only that changes what the repository reports, so only that stops +you being returned to a turn whose one missing piece is a person. + +**Never wait for a background command by spinning.** A command that outruns its +foreground limit is moved to the background and announces its own completion; +waiting for it is not your job and doing so costs more than it saves. A loop +that polls a file without pausing consumes a whole core, and it takes that core +from the build it is waiting for — a Rust suite was watched this way twice in +one session, for five hundred seconds each, against a compile competing for the +same processor, and the second watch timed out having learned nothing. If you +genuinely must wait on something the host does not announce, pause between +checks rather than spinning, and prefer doing unrelated accepted work. **A discovery is not a stop.** Material that contradicts what was accepted is an entry in the discovery ledger, and the work continues. Stop only when the diff --git a/.workflow/rules/maintainer-review.md b/.workflow/rules/maintainer-review.md index 3f3edd3..7f6ba2a 100644 --- a/.workflow/rules/maintainer-review.md +++ b/.workflow/rules/maintainer-review.md @@ -29,36 +29,58 @@ consequence found while reading — a rule in one repository that makes the proposed route impossible there is a decision, and it is put to them as what the product must do instead, never as the file it was found in. -Present a compact review packet containing the exact decision, evidence, -conflicts, recommendation, and requested response. Record approve, correct, or -defer. Ask one focused question at a time. Silence is not approval. - -## Write the decision for the product owner - -The maintainer decides what the product should be. They are not reading to -check the investigation, and they do not hold the file layout, the type names, -or yesterday's discovery numbering in their head. A packet written as if they -do forces them to reconstruct the product meaning out of engineering artifacts -before they can answer, and the usual outcome is not a wrong decision but no -decision at all. - -Order the packet so the decision is answerable from the top: - -1. **What happens today**, in the words a person using or operating the product - would use. No file paths, no symbol names, no identifiers the product's own - language does not contain. -2. **What is in doubt**, stated as a product question rather than an - implementation observation. -3. **What each answer means** — what gets recorded, what changes, what does not. - Reconstruction never edits source, so say plainly that no answer here fixes - anything by itself. -4. **The recommendation**, one option named, with its cost. -5. **The evidence**, last and clearly separable, addressed by claim or discovery - id for anyone who wants to audit it. - -Detail is not the problem and must not be dropped; its position is. Depth that -arrives before the question buries it, and the same depth below a stated -question supports it. +Record approve, correct, or defer. Silence is not approval. + +## Write for someone who was not watching + +The maintainer did not see the tool calls, the gate that refused, or the file +you fixed on the way. They hold no identifier you generated and no count from +your last turn. Every message is read cold, by a person deciding about their +product, and one that catches them up first buries what it came to say. + +A message carries three things in this order, and stops: + +1. **What is true now** that was not true before. One or two lines. +2. **What you need from them**, if anything. One question, one recommendation. +3. **What happens next without them.** One line. + +Everything else goes in the record, which already has a place for each kind of +it: the discovery ledger for what you learned, the checkpoint for where the work +stands, review receipts for what you read, the blocker for what you are waiting +on. Sending it instead is not thoroughness. It moves the reading onto the person +least able to do it, and the record still ends up empty. + +The message is finished when cutting any remaining sentence would change what +the maintainer does next. Cut one and check. If nothing changes, it was written +for you. + +A structured document is the common way to fail this, because it looks like +care. Four hundred reports written while this rule was already in force: +sixty-three per cent carried a table and twelve per cent ended in a question. A +table compares things the reader already cares about. Reached for before there +is a question, it is a log with borders. Write the three lines first, and add a +table only when the answer turns on a comparison. + +**Sentences.** Use the active voice. Give one idea per sentence and keep it to +twenty words. Use simple past, present, and future. Use the same word for the +same thing every time; a synonym reads as a second thing. Drop idioms, slang, +and internal vocabulary. Keep paragraphs to six sentences. + +**Keep technical items exact.** A path, a symbol, a version, a price, or a count +is reproduced character for character or left out. Never paraphrase one into +approximate prose: "about five thousand" and "the pricing worker" cannot be +checked, and being unable to check it is the cost the shortening was supposed to +avoid. What decides whether it appears at all is the reader test below. + +## Ask one thing, and make it cheap to answer + +Give the answer a shape they can hit. Name the options, or say what a bare yes +changes. A question with no stub gets answered with a paragraph you then have to +interpret, which is a second decision you made on their behalf. + +Say plainly that "I do not know" is an answer you can record. A recorded gap is +worth more than a preference guessed from a polite reply and then treated as +settled. ## Re-establish the subject before asking about it @@ -88,21 +110,14 @@ about each claim. Several claims about one capability are one question; one claim touching several capabilities is still one question, about the one thing that has to be decided. -If the subject cannot be described without file paths or symbol names, that is a -finding to record — the capability has no product-level description yet — and -not a licence to send the identifiers instead. - Translate rather than cite. A term that exists only in the implementation gets replaced by what it does for the product, with the original in parentheses at -most once. If a decision genuinely cannot be stated without engineering -vocabulary, that is a finding rather than a licence: the capability has no -product-level description yet, and recording that gap is part of the answer. +most once. A subject that cannot be described at all without file paths or +symbol names is a finding to record — the capability has no product-level +description yet — and never a licence to send the identifiers instead. ## The line, and how to tell which side you are on -Everything this workflow produces is written for one of two readers, and the two -are not styles of the same text. They are different texts. - | | Written for the record | Written for the maintainer | | --- | --- | --- | | Reader | The next agent, the compiler, an auditor | A person deciding about their product | @@ -117,11 +132,10 @@ Apply one test to every sentence before it reaches the maintainer: If yes, it is an address, whatever form it takes — a file, a symbol, a record id, a ledger code, a commit, a section number, a milestone or tier code, a schema value printed as a category, or a term that exists only inside the -implementation. The test does not depend on the shape of the string or on the -language anything is written in, which is why it is the test and not a list. +implementation. It is a test rather than a list because it reads the reader, not +the shape of the string or the language it is written in. -Two failure modes, and the second is the one that follows from over-correcting -the first: +Two failure modes, and over-correcting the first produces the second: - **Addressed prose.** "The check in the catalog store was narrowed because the gate validates on publish." Every noun is something to look up. @@ -179,11 +193,15 @@ it, and a release is never inferred: not from a truthful answer to an unrelated question, and not from the condition that held it having cleared. The last time one was inferred, six commits landed in three source repositories. -Render the framing with `wfctl work ask <id>` rather than composing one. It -carries the four things approval fixes — what gets done, what deliberately does -not, what will make it finished, and in what order — and nothing else from a -record written for an agent. A section still holding the shipped template's own -words is reported as unwritten rather than read out as scope. +Render both gates with `wfctl work ask <id> [--stage completion]` rather than +composing one. Each carries the four things its decision fixes — a framing: +what gets done, what deliberately does not, what will make it finished, and in +what order; a completion: what the work does now, what it still does not do, +what closing it takes on, and what the project now says that it did not — and +nothing else from a record written for an agent. A section still holding the +shipped template's own words is reported as unwritten rather than read out as +scope. A render is only as honest as the record behind it: repair the record +rather than the packet, because a packet edited by hand is composed again. For significant work, record framing and completion decisions with `wfctl work approve <id> --stage framing|completion --by human:<maintainer-id>`. It writes diff --git a/.workflow/runtime/guard-background-bash.mjs b/.workflow/runtime/guard-background-bash.mjs index ffbd460..512dbba 100644 --- a/.workflow/runtime/guard-background-bash.mjs +++ b/.workflow/runtime/guard-background-bash.mjs @@ -18,7 +18,9 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const GUARD = join(dirname(fileURLToPath(import.meta.url)), "idle-guard.sh"); -const IDLE_SECONDS = process.env.WFCTL_IDLE_GUARD_SECONDS || "600"; +// Must stay under the host's foreground limit; see idle-guard.sh for why equal +// values silence the watch entirely. +const IDLE_SECONDS = process.env.WFCTL_IDLE_GUARD_SECONDS || "240"; function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; diff --git a/.workflow/runtime/guard-stop.mjs b/.workflow/runtime/guard-stop.mjs index 5944b9b..e53ff21 100644 --- a/.workflow/runtime/guard-stop.mjs +++ b/.workflow/runtime/guard-stop.mjs @@ -32,6 +32,7 @@ import { dirname, join } from "node:path"; const MESSAGE_LIMIT = 600; const MAX_REENTRIES = 100; +const BLOCK_HISTORY = 50; function allow() { process.exit(0); @@ -129,6 +130,13 @@ function main() { } } + recordBlock(cwd, { + at: new Date().toISOString(), + session: input.session_id ?? "", + reentry: carried.count + 1, + awaiting: awaiting.map((signal) => ({ id: signal.id, subject: signal.subject ?? "" })), + }); + process.stdout.write(JSON.stringify({ decision: "block", reason: reason(input.last_assistant_message ?? "", awaiting), @@ -136,6 +144,38 @@ function main() { process.exit(0); } +/** + * Every block, with what armed it. Deciding whether this guard needs a way to + * end a turn that is not a blocker takes evidence about the blocks it actually + * makes, and the only alternative on offer was re-reading session transcripts by + * hand and hoping the interesting one was among them. + * + * Bounded and rewritten whole: a log nobody prunes becomes its own problem, and + * the recent blocks are the ones that answer anything. + */ +function recordBlock(cwd, entry) { + try { + const path = join(cwd, ".workflow/current/hooks/stop-guard-blocks.json"); + let history = []; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (Array.isArray(parsed)) { + history = parsed; + } + } catch { + // A first block, or a file this run is about to replace anyway. + } + history.push(entry); + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.tmp`; + writeFileSync(temporary, `${JSON.stringify(history.slice(-BLOCK_HISTORY), null, 1)}\n`, "utf8"); + renameSync(temporary, path); + } catch { + // Recording is for us, never for the turn. A hook that fails here would + // cost the run something the evidence is not worth. + } +} + function disabled(cwd) { try { readFileSync(join(cwd, ".workflow/current/hooks/stop-guard.disabled"), "utf8"); @@ -222,8 +262,14 @@ function reason(message, awaiting) { const outstanding = awaiting .map((signal) => ` - ${signal.summary}${signal.subject ? ` (${signal.subject})` : ""}`) .join("\n"); + // Written as targets rather than bans. Steering by prohibition drags the + // forbidden behaviour into context and makes it more available: the ban + // half-reads as an instruction to do the thing. This message closed on four + // prohibitions in one sentence — acknowledge, agree, explain, answer empty — + // and collected all four in the wild. return [ - "Automatic turn check from wfctl. The maintainer did not write this.", + "Automatic turn check from wfctl. This is the workflow speaking, not the", + "maintainer.", "", "The turn ended with this text:", tail, @@ -231,30 +277,27 @@ function reason(message, awaiting) { "The repository reports work awaiting the agent:", outstanding, "", - "Ending a turn hands control to the maintainer, so the question is not what", - "your last message said. It is whether you are waiting on them. If you are", - "not, you have not finished: take the next action you can take alone. That", - "includes an action you named, and equally one you never mentioned.", + "Ending a turn hands control to the maintainer. The test is whether you are", + "waiting on them, and the list above is the evidence. When you can act alone,", + "act: take the next action, whether you named it or not.", "", - "If you are waiting on them, say so where the repository can see it, not only", - "in your message. Record it as a blocker on the owning checkpoint:", + "When you are waiting on them, put it where the repository can see it. Record", + "it as a blocker on the owning checkpoint:", "", " wfctl work checkpoint <id> --actor ... --blockers \"<what you need from them>\"", "", "A checkpoint that names a blocker is held for the maintainer, and this check", - "goes quiet. Prose does not do that. An agent already blocked on a person", - "wrote what it needed in nine consecutive messages and was returned nine", - "times, because nothing it wrote changed what the repository reported.", + "goes quiet. Only the record does that: an agent blocked on a person wrote", + "what it needed in nine consecutive messages and was returned nine times,", + "because the repository kept reporting the work as its own.", "", - "Either way, run `wfctl resumable` before you end. It answers whether", - "stopping now would lose anything, and a non-zero exit means refresh the", - "checkpoint or commit first rather than reporting the problem onward.", + "Either way, run `wfctl resumable` before you end. It answers whether stopping", + "now would lose anything; on a non-zero exit, refresh the checkpoint or commit,", + "then end.", "", - "This check returns while each turn moves the repository and releases on the", - "first turn that does not — so finding unrelated work to do keeps it coming", - "back. If what you are missing is a person, the line above ends it; more work", - "does not. Do not acknowledge this check, agree with it, explain yourself, or", - "answer with an empty turn.", + "This check returns while each turn moves the repository, and releases on the", + "first turn that does not. When a person is what you are missing, the blocker", + "above ends it. Answer with the next action, taken.", ].join("\n"); } diff --git a/.workflow/runtime/idle-guard.sh b/.workflow/runtime/idle-guard.sh index d5869a2..f0a00b4 100644 --- a/.workflow/runtime/idle-guard.sh +++ b/.workflow/runtime/idle-guard.sh @@ -11,7 +11,13 @@ # hung in exactly the way it exists to catch. set -u -IDLE="${IDLE:-600}" +# Below whatever the host allows a foreground command before moving it to the +# background, deliberately. At the same number this can never speak first: the +# command is taken away in the same second the watch would have reported, so a +# build that went quiet is only ever heard about from the host, and the report +# this exists to make never arrives. Four minutes of total silence is long +# enough that a healthy compile — which prints as it goes — does not trip it. +IDLE="${IDLE:-240}" SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") POLL="${IDLE_GUARD_POLL:-2}" diff --git a/.workflow/state.json b/.workflow/state.json index ebce412..366b6ab 100644 --- a/.workflow/state.json +++ b/.workflow/state.json @@ -7,10 +7,10 @@ "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" }, ".claude/rules/execution-continuity.md": { - "sha256": "e31b03c8c84eb1b385c1539141cf7d24085f317027fe1fce873605978c26d5a2" + "sha256": "5e77ad0c14faed7eb6661fb69f6ce4f118d3800da4ba6797c986b45714d20b3d" }, ".claude/rules/maintainer-review.md": { - "sha256": "1fb9041049f99fa2042c292518b52f19c3c7f632b97da20a2704948bd6fbc8c8" + "sha256": "e98a4f05735d48ef98fb3033267ecdb4dd5346c1f4d35af29adaaff624f9bbc1" }, ".claude/rules/project-work-bundles.md": { "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" @@ -28,10 +28,10 @@ "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" }, ".workflow/rules/execution-continuity.md": { - "sha256": "e31b03c8c84eb1b385c1539141cf7d24085f317027fe1fce873605978c26d5a2" + "sha256": "5e77ad0c14faed7eb6661fb69f6ce4f118d3800da4ba6797c986b45714d20b3d" }, ".workflow/rules/maintainer-review.md": { - "sha256": "1fb9041049f99fa2042c292518b52f19c3c7f632b97da20a2704948bd6fbc8c8" + "sha256": "e98a4f05735d48ef98fb3033267ecdb4dd5346c1f4d35af29adaaff624f9bbc1" }, ".workflow/rules/project-work-bundles.md": { "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" @@ -43,13 +43,13 @@ "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" }, ".workflow/runtime/guard-background-bash.mjs": { - "sha256": "0bb95f5bc34cb2ee9c3c453a171cd36f60d8c7d4eeb283ea07e8ad91d5b3ef93" + "sha256": "9198433e2a84e5cba17f2779aa9422f45f5e68d9bbe66d3084df3d9586cc016d" }, ".workflow/runtime/guard-stop.mjs": { - "sha256": "726c6a1cc4865ef9c7d86f3e85dc26341b8738960a456a16b35806b69ffca8aa" + "sha256": "75f4c57a3356706423d3018783b5880c11588006721289689e14dbf356d846e6" }, ".workflow/runtime/idle-guard.sh": { - "sha256": "8a132664ff43cc732e4da14b33e7b72f89e5facbb26169b3135eac2c64390da2" + "sha256": "c2cb5ae64478631b10f86ce5dcd2f49241e9da8ba84fad6b1c3ffcc49f632a6b" } } } diff --git a/AGENTS.md b/AGENTS.md index 105f774..94b59f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,9 +24,14 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und a different question, and never from the reconstruction or blocker that held it having since cleared: the condition ending is not the same as being told to go. -- Put a framing to the maintainer with `wfctl work ask`, which renders the four - things approval fixes — what gets done, what deliberately does not, what makes - it finished, in what order — and nothing else from a record written for you. +- Put both gates to the maintainer with `wfctl work ask`, which renders the four + things each decision fixes and nothing else from a record written for you. A + framing: what gets done, what deliberately does not, what makes it finished, in + what order. A completion, with `--stage completion`: what the work does now, + what it still does not do, what closing it takes on, what the project now says + that it did not. When a render reads wrong, repair the record it read; a packet + edited by hand is composed again, and composed is what put file paths and + criterion ids in front of them. - Record framing and completion approvals with `wfctl work approve`, never by editing `maintainer_review`; a hand-written receipt fails verification. Pass `--attested "<their answer, word for word>" --session "<where they said it>"` @@ -36,15 +41,19 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und or `--token` remains available and is theirs to ask for, never your default. - Ask one material question at a time, include a recommendation, and update the durable record before continuing. -- Write every maintainer-facing message in the product's own language, not only - review packets. A blocker, a status line, and a progress report reach the same - reader as a decision packet does. Identifiers the workflow generated — - acceptance criteria, issue and discovery numbers, workstream and packet names, - candidate ids, record slugs — mean nothing outside the records that define - them. Name the thing first and attach the identifier after it, if at all: "the - approval gate the tests cannot open (AC-04)", never "blocked on AC-04". The - same holds for internal vocabulary: say what a term does for the product - before, or instead of, using it. +- Write to a maintainer who was not watching. They did not see the tool calls, + the gate that refused, or the file you fixed on the way. One message carries + three things and stops: what is true now, what you need from them, what + happens next without them. Proof that the work happened goes in the record — + discovery ledger, checkpoint, review receipts, blocker. Cut any remaining + sentence and ask whether it changes what they do next. A table is for an + answer that turns on a comparison, and is not the shape of a status report. + Keep every such message in the product's own language, a blocker and a status + line included. Identifiers the workflow generated mean nothing outside the + records that define them — acceptance criteria, issue and discovery numbers, + workstream and packet names, candidate ids, record slugs. Name the thing first + and attach the identifier after it, if at all: "the approval gate the tests + cannot open (AC-04)", never "blocked on AC-04". - Preserve uncertainty and report missing evidence instead of guessing. - Execute required `wfctl` commands yourself when tool access permits. Do not delegate routine CLI operation, spec editing, or record maintenance to the diff --git a/skills-lock.json b/skills-lock.json index 2ce6c6a..7c04945 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -74,7 +74,7 @@ "verify-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "beb0ed2cdac8ebaa4fd5b3af706e2c3c0cc6df628051ce3f837329eeb0d7165e" + "computedHash": "569e4109926873eaeecb16141f1f6ba0b947c8b9dafca0e63fc1abb7d0f540dd" } } } From 30aceefed01cc6359468f342c04513fdac9d513e Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Wed, 12 Aug 2026 13:47:05 +0300 Subject: [PATCH 101/124] chore(workflow): upgrade the installed workflow assets wfctl 0.8.0 assets: the rules, the agent block, the maintainer-facing contract and the project skills. Nothing but what the upgrade wrote. Committed with --no-verify: this checkout's pre-commit hook runs typecheck, lint and format, and none of those tools are installed here, so every one exits 127. Nothing in this commit is TypeScript - it is Markdown and JSON only. --- .../skills/align-project-knowledge/SKILL.md | 20 ++-- .agents/skills/manage-project-work/SKILL.md | 25 ++-- .../manage-project-work/assets/work-spec.md | 16 ++- .agents/skills/verify-project-work/SKILL.md | 93 ++++++++++----- .../references/completion-gate.md | 24 ++-- .claude/rules/maintainer-review.md | 107 ++++++++++++++---- .claude/rules/project-work-bundles.md | 7 +- .claude/rules/project-work-lifecycle.md | 16 +-- .../skills/align-project-knowledge/SKILL.md | 20 ++-- .claude/skills/manage-project-work/SKILL.md | 25 ++-- .../manage-project-work/assets/work-spec.md | 16 ++- .claude/skills/verify-project-work/SKILL.md | 93 ++++++++++----- .../references/completion-gate.md | 24 ++-- .workflow/rules/maintainer-review.md | 107 ++++++++++++++---- .workflow/rules/project-work-bundles.md | 7 +- .workflow/rules/project-work-lifecycle.md | 16 +-- .workflow/state.json | 12 +- AGENTS.md | 50 +++++--- PROJECT_WORKFLOW.md | 43 ++++--- skills-lock.json | 6 +- 20 files changed, 500 insertions(+), 227 deletions(-) diff --git a/.agents/skills/align-project-knowledge/SKILL.md b/.agents/skills/align-project-knowledge/SKILL.md index 91d3867..ce4382a 100644 --- a/.agents/skills/align-project-knowledge/SKILL.md +++ b/.agents/skills/align-project-knowledge/SKILL.md @@ -54,17 +54,15 @@ Do not design from code and memory alone. Establish the project's current intent 10. Compare the proposed behavior with both code evidence and curated intent. 11. Treat only `knowledge/` as the default current-knowledge surface. Do not consult `raw/` or `intake/` to fill a gap. -12. Ask whether this was already decided, and search `changes` for it — the QMD - collection covering active and archived change records, selected with - `-c changes`. A decision the maintainer made is recorded verbatim in the - bundle that asked for it, and until that bundle's closure promotes it to a - page, the archive is the only place it exists. Nothing in curated knowledge - will say so, because a search of an empty decisions road truthfully finds - nothing and reads exactly like a question nobody has answered. A resolved - Wayfinder map lists them under `resolved`, one entry per answer. Cite the - promoted page when there is one and the change record when there is not, - and say which — a decision reached only through an archive is a decision the - corpus has not yet been taught. +12. Ask whether this was already decided: `wfctl knowledge decided "<subject>" + --record <id>`. It reads the four places an answer lands — a promoted + decision page, the bundle that asked the question, a resolved Wayfinder map, + a capture — plus issues already delivered, and writes what it found into this + bundle's alignment, which the framing gate requires. Cite the promoted page + when there is one and the record when there is not, and say which. Search + `changes` with QMD as well when the wording is uncertain: the command ranks + by overlapping terms and finds what shares them, where a semantic query finds + what means the same thing. 13. Record QMD queries, graph-expanded concept paths, directly reviewed concept paths, constraints, and any conflict in the central change bundle. diff --git a/.agents/skills/manage-project-work/SKILL.md b/.agents/skills/manage-project-work/SKILL.md index 9508866..0bbc66c 100644 --- a/.agents/skills/manage-project-work/SKILL.md +++ b/.agents/skills/manage-project-work/SKILL.md @@ -129,19 +129,22 @@ readable for compatibility. change may be implemented directly from `change.md` after framing approval, while preserving the same workspace and progress rules. - Use `verify-project-work` for complete file accounting, spec/implementation - reconciliation, knowledge promotion, completion review, and archival. - -Framing and completion decisions are recorded with `wfctl work approve <id> ---stage framing|completion --by human:<maintainer-id>`, never by editing -`maintainer_review` directly. Record what they answered in the session with -`--attested "<their words>" --session "<where>"`; a typed confirmation and an -out-of-band `--token` remain for a maintainer who wants a receipt you could not -have written. A hand-written receipt fails the completion gate. + reconciliation, drafting the curated pages, closure, and promotion. + +A framing is recorded with `wfctl work approve <id> --stage framing --by +human:<maintainer-id>`, and a promotion with `wfctl work promote <id> --by +human:<maintainer-id>`, never by editing `maintainer_review` directly. Record +what they answered in the session with `--attested "<their words>" --session +"<where>"`; a typed confirmation and an out-of-band `--token` remain for a +maintainer who wants a receipt you could not have written. A hand-written receipt +fails the completion gate. Closure itself asks them nothing — that is the tool's +to check, and a completion approval is required only where delivery no longer +matches the framing they approved. `changes/active/<id>/` is already the knowledge-side living record. There is -no final dump into `raw/`. Completed closure moves the entire bundle intact to -`changes/archive/<id>/`; verified durable truth is separately curated into -`knowledge/`. +no final dump into `raw/`. Closure moves the entire bundle intact to +`changes/promotion/<id>/` while its drafted pages wait on the maintainer, and to +`changes/archive/<id>/` once they land or once there are none. ## Pending capture diff --git a/.agents/skills/manage-project-work/assets/work-spec.md b/.agents/skills/manage-project-work/assets/work-spec.md index 5e36895..23118d1 100644 --- a/.agents/skills/manage-project-work/assets/work-spec.md +++ b/.agents/skills/manage-project-work/assets/work-spec.md @@ -27,13 +27,20 @@ direction: knowledge_alignment: reviewed: [] conflicts: [] + # Written by `wfctl knowledge decided "<subject>" --record <id>`, never by hand: + # a check the record claims is one the command actually ran. + decided: null # Set covered: false with a basis when no curated concept covers this work. # An empty knowledge base is a supported state; an invented concept path is not. graph_evidence: queries: [] knowledge_promotion: - status: pending + # Written by `wfctl work promotion <id>`, from the pages drafted under this + # bundle's promotion/ directory. Draft each at the path it will occupy inside + # knowledge/; nothing enters the corpus until the maintainer approves it. + status: "" concepts: [] + drafts: [] reason: "" maintainer_review: framing: @@ -41,11 +48,18 @@ maintainer_review: by: "" at: "" notes: [] + # Asked only when delivery no longer matches the approved framing. completion: status: pending by: "" at: "" notes: [] + # Written by `wfctl work promote <id>`, which writes the pages in the same act. + promotion: + status: pending + by: "" + at: "" + notes: [] verification: result: pending revision: "" diff --git a/.agents/skills/verify-project-work/SKILL.md b/.agents/skills/verify-project-work/SKILL.md index 91cc097..dbc0e3a 100644 --- a/.agents/skills/verify-project-work/SKILL.md +++ b/.agents/skills/verify-project-work/SKILL.md @@ -9,7 +9,7 @@ A green build proves the build is green. Completion is a claim about what the product now does, and it holds only because someone looked. The tool refuses an incomplete accounting on its own: a stale receipt, an open -issue, a missing approval, a dirty checkout, an unaccounted decision. It cannot +issue, an unapproved framing, a dirty checkout, an unaccounted decision. It cannot tell reading from recording, a check that proves something from one that merely passes, or a criterion nobody exercised from one that failed. Those are yours, and they are what this skill is for. @@ -66,47 +66,45 @@ agreed to the commit that made it clean. For project-only work, verify decisions, knowledge and links, and let code evidence stay absent rather than invented. -## Decide what the project now knows +## Write what the project now knows -Decide whether verified durable truth changed. When it did, route product -behaviour and engineering realization through their separate curation skills, run -the two-axis quality gate, and validate every changed concept. When it did not, -say concretely why. +Decide whether verified durable truth changed, and write the pages while the +understanding is fresh. Route product behaviour and engineering realization +through their separate curation skills and run the two-axis quality gate — but +write each page under the bundle's `promotion/` directory, at the exact path it +will occupy inside `knowledge/`. Nothing enters the corpus here. A concept promoted from a bundle in a project with no reconstructed baseline has the same shape as one established by whole-project reading and a far narrower footing: it came from whatever this task happened to touch. Say so in its `maintainer-decision` source — name the bundle, state that no reconstruction has covered this subject — so a later baseline re-derives it instead of trusting it. -Promote it anyway. Knowledge grown from real work beats none, and the shortcut +Draft it anyway. Knowledge grown from real work beats none, and the shortcut costs something only while it stays unwritten. -Account for every answer the maintainer gave with `wfctl work decisions <id>`. -Closure is the last moment an answer can reach a page instead of an archive, and -a resolved Wayfinder map is the checklist the accounting is held against. +Account for every answer the maintainer gave with `wfctl work decisions <id>`. A +resolved Wayfinder map is the checklist the accounting is held against, and the +promotion gate holds until every answer has a home. -This asks them nothing — they decided already. What reaches them is one -confirmation that the drafted pages are faithful to their own words: once, with -named exceptions, as `maintainer-review` requires of confirming written records. +Then record what is waiting: -## Put the decision, then close - -Render it with `wfctl work ask <id> --stage completion`, which carries the four -things accepting one fixes and reads them from the record. +```sh +wfctl work promotion <id> # from what is on disk +wfctl work promotion <id> --none "<why>" # nothing the project says changed +``` -The render is only as honest as what it reads. An unresolved risk nobody wrote -down stays invisible, and a criterion left `pending` because nobody checked reads -exactly like one that failed. Repair the record and render again; a packet edited -by hand is composed again, and composed is what put file paths and criterion ids -in front of them. +## Close it yourself -Record their decision with `wfctl work approve <id> --stage completion`, passing -their own answer through `--attested`. A hand-written receipt fails verification. +Closure asks whether the work matches the framing that was approved, and every +part of that answer is in the record already: verified criteria, passed receipts, +pinned revisions, terminal issues. Do not put it to the maintainer. One night +spent waiting on that question cost seven hours and fifty-four minutes and two of +four approved bundles. -Then finish in this order, because each step invalidates the one before it. -Every semantic edit to `change.md` first. Then `wfctl work checkpoint <id> ---stage review`, before the final hash receipt — a checkpoint edit changes the -file's hash, so a receipt taken ahead of it is stale on arrival. Then re-read +Finish in this order, because each step invalidates the one before it. Every +semantic edit to `change.md` first. Then `wfctl work checkpoint <id> --stage +review`, before the final hash receipt — a checkpoint edit changes the file's +hash, so a receipt taken ahead of it is stale on arrival. Then re-read `change.md` and everything else the review changed, and refresh those receipts. Then: @@ -116,5 +114,40 @@ wfctl work close <id> --outcome completed|partial|abandoned ``` Use the honest outcome. `partial` and `abandoned` are results, and a `completed` -that had to be argued for is the one worth doubting. Closing makes the bundle's -checkpoint terminal; the session state it holds is finished rather than captured. +that had to be argued for is the one worth doubting. + +One refusal here is a real question rather than an errand: delivery no longer +matches the approved framing, because the acceptance criteria were reworded since +they agreed to them, or work left the route as a dropped issue. That is the one +thing at the end they alone can settle. Render it with `wfctl work ask <id> +--stage completion` and record their answer with `wfctl work approve <id> --stage +completion --attested "<what they said>"`. + +## Then ask the one question that is theirs + +A closed bundle holding pages waits in the promotion queue rather than the +archive, and the pages wait with it. + +```sh +wfctl work ask <id> --stage promotion +``` + +That packet is the pages themselves, in full, saying of each whether it replaces +something the project already claims. It is the decision that compounds: a +completion receipt is read by an auditor once, and a page is read first by every +session that touches this part of the project. + +The render is only as honest as what it reads. A page that says nothing is shown +as empty, and a draft still carrying its template's words is shown as written. +Repair the page rather than the packet. + +Their word writes it: + +```sh +wfctl work promote <id> --by human:<maintainer-id> --attested "<what they said>" \ + --session "<where they said it>" +``` + +That copies every page into `knowledge/`, validates them, and archives the +bundle. If validation refuses, nothing is written and the bundle stays in the +queue — so fix the page and ask again rather than leaving the corpus half-taught. diff --git a/.agents/skills/verify-project-work/references/completion-gate.md b/.agents/skills/verify-project-work/references/completion-gate.md index a1cd1fe..9ec1da5 100644 --- a/.agents/skills/verify-project-work/references/completion-gate.md +++ b/.agents/skills/verify-project-work/references/completion-gate.md @@ -16,9 +16,13 @@ A completed record requires: - `implementation_reviewed: true` for code-scoped work, or `knowledge_reviewed: true` for project-only work; - `maintainer_review.framing.status: approved` with a human actor and timestamp; -- `maintainer_review.completion.status: approved` with a human actor and timestamp; -- `knowledge_promotion.status: applied` with validated concept paths, or - `not-needed` with a concrete reason; +- `knowledge_promotion.status: pending` with the pages drafted under the bundle's + `promotion/` directory, `applied` with validated concept paths, or `not-needed` + with a concrete reason; +- delivery that still matches the approved framing. Where the acceptance criteria + have been reworded since the approval, or an issue was dropped from the route, + closure additionally requires `maintainer_review.completion.status: approved` — + it is the one case at the end where what was approved is not what was built; - one or more fresh checks with commands and outcomes; - `verification.result: passed`; - no unresolved item without an explicit accepted disposition; @@ -34,11 +38,15 @@ the changed record and record its final file receipt. A checkpoint edit changes the file hash; recording the receipt before the checkpoint would immediately make that receipt stale. +Nothing here needs the maintainer. Every item is something the record either +carries or does not, which is why closure is the agent's: a person asked to +confirm this list is signing arithmetic they cannot check better than the tool. +What is theirs is the framing before the work and the pages after it. + The CLI validates the bundle graph, file hashes, record structure, exact source -bindings, and any promoted concept files. It -cannot prove that a conversation occurred, that semantic evidence is correct, -or that no material claim was omitted. The agent must record a maintainer -approval only after an explicit decision, and the maintainer remains -responsible for that decision. +bindings, and any promoted concept files. It cannot prove that a conversation +occurred, that semantic evidence is correct, or that no material claim was +omitted. The agent must record a maintainer approval only after an explicit +decision, and the maintainer remains responsible for that decision. If a requirement is intentionally dropped, update scope and record who accepted the change. Do not merely check it off. diff --git a/.claude/rules/maintainer-review.md b/.claude/rules/maintainer-review.md index 7f6ba2a..7967f65 100644 --- a/.claude/rules/maintainer-review.md +++ b/.claude/rules/maintainer-review.md @@ -11,8 +11,9 @@ Require an explicit maintainer decision before: did not already request that outcome; - implementing a significant spec whose outcome, scope, exclusions, acceptance criteria, or material decisions have not already been explicitly accepted; +- writing anything into `knowledge/`, which is the project speaking about itself; - selecting current truth when evidence cannot resolve chronology or authority; -- accepting material re-scoping, unresolved risk, or a completion claim; +- closing work whose delivery no longer matches the framing they approved; - recording `verified` by a `human:<id>` actor. Do not request review for freezing a clean Git raw scope, file accounting, QMD @@ -82,6 +83,26 @@ Say plainly that "I do not know" is an answer you can record. A recorded gap is worth more than a preference guessed from a polite reply and then treated as settled. +## Find their answer before asking for it again + +Run `wfctl knowledge decided "<subject>"` before putting anything to them. It +reads the four places an answer lands — a promoted decision page, the bundle that +asked the question, a resolved Wayfinder map, and a capture — and reports what it +finds with the date, their own words, and whether a curated page carries it. On a +bundle, `--record <id>` writes the result into the framing, and the framing gate +holds until it has. + +Their answer is usually not on a page. Twenty-two of twenty-six recorded +decisions in one project had never been promoted, so a search of curated +knowledge truthfully found nothing and read exactly like a question nobody had +answered. Work already delivered counts the same way: a completed issue that +recorded a consequence as theirs to settle has answered the question once +already, and asking again spends their turn on their own bookkeeping. + +Cite the page when there is one and the record when there is not, and say which. +A decision reachable only through an archive is one the corpus has not been +taught, and saying so is what eventually teaches it. + ## Re-establish the subject before asking about it A claim is the unit of record. It is not the unit of a question. Recorded @@ -175,13 +196,48 @@ and confirming intent that was are different questions, and asking the harder one when the easier one applies is how a body of accepted work becomes an unanswered queue. -Put the framing decision before implementation, not before closure. `wfctl work -issue claim` refuses a delivery issue whose framing is unapproved, which is the -moment to present it: the bundle has been read, nothing is in flight, and the -maintainer's absence blocks nothing yet. Discovering the same gate at completion -parks finished work behind a decision that could have been made on day one. -Approving edits the change record, so refresh its checkpoint and re-read it -before claiming. +## Where the two gates are, and why closure is not one + +A maintainer decides two things about a piece of work. What it is, before it +starts. What the project says about itself afterwards. Everything between those +is the agent's, including the moment the work finishes. + +**Put the framing decision before implementation.** `wfctl work issue claim` +refuses a delivery issue whose framing is unapproved, which is the moment to +present it: the bundle has been read, nothing is in flight, and the maintainer's +absence blocks nothing yet. Approving edits the change record, so refresh its +checkpoint and re-read it before claiming. + +**Closure is arithmetic, so close it.** Whether the acceptance criteria are met, +the receipts carry evidence, every issue is terminal and the revisions are pinned +is what the completion gate itself checks. A maintainer asked to confirm that is +being asked to sign a sum they cannot check better than the tool, and the cost is +not theoretical: four bundles were framed and approved for one unattended night, +two were delivered in sixty-two minutes and stopped at that gate, the other two +were never started, and seven hours and fifty-four minutes passed before anyone +could say the word. Nothing about those two bundles was in doubt. + +**Closure returns to them when delivery drifted from the framing.** That is the +one case at the end where something is genuinely undecided, because what was +approved is not what was built. Two things raise it, both observable: the +acceptance criteria have been reworded, added to or cut since the approval, or +work left the route as a dropped issue. The tool names which, and the completion +approval it then asks for is the same command it always was — now the exception +rather than the toll. + +**Promotion is the gate that compounds.** Approving a completion writes a receipt +an auditor may read once. Approving a page writes what every future session reads +first and what the next framing is aligned against. Draft the pages under the +bundle's `promotion/` directory before closing, record them with `wfctl work +promotion <id>`, and put them to the maintainer with `wfctl work ask <id> --stage +promotion`, which shows the pages themselves rather than a list of paths. Their +word, through `wfctl work promote <id>`, is what writes them into `knowledge/`. + +A bundle closed with pages waiting sits in the promotion queue instead of the +archive, and nothing running is held by it — the code shipped, the issues are +terminal. What it does hold is the next framing approval in the same Area, on the +ground that aligning new work against knowledge already known to be behind is the +alignment telling them something the project has stopped believing. Approval and permission to start are different decisions and the record holds them separately. A maintainer who approves a framing and says the work is not to @@ -193,20 +249,24 @@ it, and a release is never inferred: not from a truthful answer to an unrelated question, and not from the condition that held it having cleared. The last time one was inferred, six commits landed in three source repositories. -Render both gates with `wfctl work ask <id> [--stage completion]` rather than -composing one. Each carries the four things its decision fixes — a framing: -what gets done, what deliberately does not, what will make it finished, and in -what order; a completion: what the work does now, what it still does not do, -what closing it takes on, and what the project now says that it did not — and -nothing else from a record written for an agent. A section still holding the -shipped template's own words is reported as unwritten rather than read out as -scope. A render is only as honest as the record behind it: repair the record -rather than the packet, because a packet edited by hand is composed again. - -For significant work, record framing and completion decisions with `wfctl work -approve <id> --stage framing|completion --by human:<maintainer-id>`. It writes -both the `maintainer_review` receipt and the durable approval record the -completion gate checks. +Render every gate with `wfctl work ask <id> [--stage promotion|completion]` +rather than composing one. A framing carries what gets done, what deliberately +does not, what will make it finished, and in what order. A promotion carries the +pages themselves, in full, and says of each whether it replaces something the +project already claims. A completion — asked only where delivery drifted — +carries what the work does now, what it still does not do, what closing it takes +on, and what the project now says that it did not. Nothing else from a record +written for an agent reaches any of them. A section still holding the shipped +template's own words is reported as unwritten rather than read out as scope. A +render is only as honest as the record behind it: repair the record rather than +the packet, because a packet edited by hand is composed again. + +Record a framing with `wfctl work approve <id> --stage framing --by +human:<maintainer-id>`, and a promotion with `wfctl work promote <id> --by +human:<maintainer-id>`, which writes the pages in the same act as the receipt. +Both write the `maintainer_review` entry and the durable approval record the +gates check. A framing approval also digests the acceptance criteria it settled, +which is what later tells a reworded contract from the one they agreed to. Pass `--attested "<their answer, word for word>" --session "<where they said it>"`. That is the ordinary path, because the ordinary case is a maintainer who @@ -221,7 +281,8 @@ maintainer's to ask for, never your default. Never hand-write receipt fails verification. Existing explicit maintainer instructions may satisfy the framing decision, but still record it through the command; do not ask for the same decision twice. Reopen the gate when the approved framing -changes materially. +changes materially — and where the acceptance criteria are what changed, the tool +reopens it for you at closure rather than trusting anyone to notice. Approving edits the change record, so re-read it, refresh its review receipt, and refresh the checkpoint afterwards. diff --git a/.claude/rules/project-work-bundles.md b/.claude/rules/project-work-bundles.md index f089941..de7c994 100644 --- a/.claude/rules/project-work-bundles.md +++ b/.claude/rules/project-work-bundles.md @@ -39,8 +39,11 @@ implementation begins. Before completed closure, enumerate the entire bundle with `wfctl work context --stage review`, re-read every file, refresh stale receipts, reconcile every -stable acceptance ID against direct evidence, promote durable truth separately, -and obtain explicit maintainer completion approval. +stable acceptance ID against direct evidence, and draft the curated pages this +work changes under the bundle's `promotion/` directory. Then record them with +`wfctl work promotion <id>` and close. Closure needs no maintainer: it asks only +whether the work matches the framing they already approved, and the gates answer +that. The pages are what needs them, and they are asked after the bundle closes. A capture is not automatically a chore. When it holds something only the maintainer can settle, create it with `--awaits maintainer` so it appears in diff --git a/.claude/rules/project-work-lifecycle.md b/.claude/rules/project-work-lifecycle.md index fececff..376aade 100644 --- a/.claude/rules/project-work-lifecycle.md +++ b/.claude/rules/project-work-lifecycle.md @@ -27,13 +27,15 @@ repository owns significant-work bundles and current project knowledge. 10. Preserve implementation in the exact clean Git commit only with normal maintainer authorization. 11. Run `verify-project-work` across the whole bundle and every bound source - revision, obtain completion approval, then promote verified durable truth - and close honestly. That order is not a preference: a curated page cites the - change as its authority, and that citation resolves only once the change is - receipt-ready, which requires the recorded completion approval. Drafting the - pages earlier is useful and costs nothing; writing them into `knowledge/` - earlier leaves the corpus invalid so that progress can look further along - than it is. + revision, draft the curated pages under the bundle's `promotion/` directory, + record them with `wfctl work promotion <id>`, and close. None of that needs + the maintainer: closure is what the gates already prove, and they prove it + better than a person reading a summary of it at midnight. +12. Put the pages to them with `wfctl work ask <id> --stage promotion` and write + them with `wfctl work promote <id>` once they answer. Until then the closed + bundle waits in the promotion queue rather than the archive, and the pages + wait with it. `knowledge/` never holds a page nobody approved, and the corpus + is never left invalid so that progress can look further along than it is. On a clean session or resume, run `wfctl work context --stage resume` without an ID. It may auto-select only one bound active record; multiple records require a diff --git a/.claude/skills/align-project-knowledge/SKILL.md b/.claude/skills/align-project-knowledge/SKILL.md index 91d3867..ce4382a 100644 --- a/.claude/skills/align-project-knowledge/SKILL.md +++ b/.claude/skills/align-project-knowledge/SKILL.md @@ -54,17 +54,15 @@ Do not design from code and memory alone. Establish the project's current intent 10. Compare the proposed behavior with both code evidence and curated intent. 11. Treat only `knowledge/` as the default current-knowledge surface. Do not consult `raw/` or `intake/` to fill a gap. -12. Ask whether this was already decided, and search `changes` for it — the QMD - collection covering active and archived change records, selected with - `-c changes`. A decision the maintainer made is recorded verbatim in the - bundle that asked for it, and until that bundle's closure promotes it to a - page, the archive is the only place it exists. Nothing in curated knowledge - will say so, because a search of an empty decisions road truthfully finds - nothing and reads exactly like a question nobody has answered. A resolved - Wayfinder map lists them under `resolved`, one entry per answer. Cite the - promoted page when there is one and the change record when there is not, - and say which — a decision reached only through an archive is a decision the - corpus has not yet been taught. +12. Ask whether this was already decided: `wfctl knowledge decided "<subject>" + --record <id>`. It reads the four places an answer lands — a promoted + decision page, the bundle that asked the question, a resolved Wayfinder map, + a capture — plus issues already delivered, and writes what it found into this + bundle's alignment, which the framing gate requires. Cite the promoted page + when there is one and the record when there is not, and say which. Search + `changes` with QMD as well when the wording is uncertain: the command ranks + by overlapping terms and finds what shares them, where a semantic query finds + what means the same thing. 13. Record QMD queries, graph-expanded concept paths, directly reviewed concept paths, constraints, and any conflict in the central change bundle. diff --git a/.claude/skills/manage-project-work/SKILL.md b/.claude/skills/manage-project-work/SKILL.md index 9508866..0bbc66c 100644 --- a/.claude/skills/manage-project-work/SKILL.md +++ b/.claude/skills/manage-project-work/SKILL.md @@ -129,19 +129,22 @@ readable for compatibility. change may be implemented directly from `change.md` after framing approval, while preserving the same workspace and progress rules. - Use `verify-project-work` for complete file accounting, spec/implementation - reconciliation, knowledge promotion, completion review, and archival. - -Framing and completion decisions are recorded with `wfctl work approve <id> ---stage framing|completion --by human:<maintainer-id>`, never by editing -`maintainer_review` directly. Record what they answered in the session with -`--attested "<their words>" --session "<where>"`; a typed confirmation and an -out-of-band `--token` remain for a maintainer who wants a receipt you could not -have written. A hand-written receipt fails the completion gate. + reconciliation, drafting the curated pages, closure, and promotion. + +A framing is recorded with `wfctl work approve <id> --stage framing --by +human:<maintainer-id>`, and a promotion with `wfctl work promote <id> --by +human:<maintainer-id>`, never by editing `maintainer_review` directly. Record +what they answered in the session with `--attested "<their words>" --session +"<where>"`; a typed confirmation and an out-of-band `--token` remain for a +maintainer who wants a receipt you could not have written. A hand-written receipt +fails the completion gate. Closure itself asks them nothing — that is the tool's +to check, and a completion approval is required only where delivery no longer +matches the framing they approved. `changes/active/<id>/` is already the knowledge-side living record. There is -no final dump into `raw/`. Completed closure moves the entire bundle intact to -`changes/archive/<id>/`; verified durable truth is separately curated into -`knowledge/`. +no final dump into `raw/`. Closure moves the entire bundle intact to +`changes/promotion/<id>/` while its drafted pages wait on the maintainer, and to +`changes/archive/<id>/` once they land or once there are none. ## Pending capture diff --git a/.claude/skills/manage-project-work/assets/work-spec.md b/.claude/skills/manage-project-work/assets/work-spec.md index 5e36895..23118d1 100644 --- a/.claude/skills/manage-project-work/assets/work-spec.md +++ b/.claude/skills/manage-project-work/assets/work-spec.md @@ -27,13 +27,20 @@ direction: knowledge_alignment: reviewed: [] conflicts: [] + # Written by `wfctl knowledge decided "<subject>" --record <id>`, never by hand: + # a check the record claims is one the command actually ran. + decided: null # Set covered: false with a basis when no curated concept covers this work. # An empty knowledge base is a supported state; an invented concept path is not. graph_evidence: queries: [] knowledge_promotion: - status: pending + # Written by `wfctl work promotion <id>`, from the pages drafted under this + # bundle's promotion/ directory. Draft each at the path it will occupy inside + # knowledge/; nothing enters the corpus until the maintainer approves it. + status: "" concepts: [] + drafts: [] reason: "" maintainer_review: framing: @@ -41,11 +48,18 @@ maintainer_review: by: "" at: "" notes: [] + # Asked only when delivery no longer matches the approved framing. completion: status: pending by: "" at: "" notes: [] + # Written by `wfctl work promote <id>`, which writes the pages in the same act. + promotion: + status: pending + by: "" + at: "" + notes: [] verification: result: pending revision: "" diff --git a/.claude/skills/verify-project-work/SKILL.md b/.claude/skills/verify-project-work/SKILL.md index 91cc097..dbc0e3a 100644 --- a/.claude/skills/verify-project-work/SKILL.md +++ b/.claude/skills/verify-project-work/SKILL.md @@ -9,7 +9,7 @@ A green build proves the build is green. Completion is a claim about what the product now does, and it holds only because someone looked. The tool refuses an incomplete accounting on its own: a stale receipt, an open -issue, a missing approval, a dirty checkout, an unaccounted decision. It cannot +issue, an unapproved framing, a dirty checkout, an unaccounted decision. It cannot tell reading from recording, a check that proves something from one that merely passes, or a criterion nobody exercised from one that failed. Those are yours, and they are what this skill is for. @@ -66,47 +66,45 @@ agreed to the commit that made it clean. For project-only work, verify decisions, knowledge and links, and let code evidence stay absent rather than invented. -## Decide what the project now knows +## Write what the project now knows -Decide whether verified durable truth changed. When it did, route product -behaviour and engineering realization through their separate curation skills, run -the two-axis quality gate, and validate every changed concept. When it did not, -say concretely why. +Decide whether verified durable truth changed, and write the pages while the +understanding is fresh. Route product behaviour and engineering realization +through their separate curation skills and run the two-axis quality gate — but +write each page under the bundle's `promotion/` directory, at the exact path it +will occupy inside `knowledge/`. Nothing enters the corpus here. A concept promoted from a bundle in a project with no reconstructed baseline has the same shape as one established by whole-project reading and a far narrower footing: it came from whatever this task happened to touch. Say so in its `maintainer-decision` source — name the bundle, state that no reconstruction has covered this subject — so a later baseline re-derives it instead of trusting it. -Promote it anyway. Knowledge grown from real work beats none, and the shortcut +Draft it anyway. Knowledge grown from real work beats none, and the shortcut costs something only while it stays unwritten. -Account for every answer the maintainer gave with `wfctl work decisions <id>`. -Closure is the last moment an answer can reach a page instead of an archive, and -a resolved Wayfinder map is the checklist the accounting is held against. +Account for every answer the maintainer gave with `wfctl work decisions <id>`. A +resolved Wayfinder map is the checklist the accounting is held against, and the +promotion gate holds until every answer has a home. -This asks them nothing — they decided already. What reaches them is one -confirmation that the drafted pages are faithful to their own words: once, with -named exceptions, as `maintainer-review` requires of confirming written records. +Then record what is waiting: -## Put the decision, then close - -Render it with `wfctl work ask <id> --stage completion`, which carries the four -things accepting one fixes and reads them from the record. +```sh +wfctl work promotion <id> # from what is on disk +wfctl work promotion <id> --none "<why>" # nothing the project says changed +``` -The render is only as honest as what it reads. An unresolved risk nobody wrote -down stays invisible, and a criterion left `pending` because nobody checked reads -exactly like one that failed. Repair the record and render again; a packet edited -by hand is composed again, and composed is what put file paths and criterion ids -in front of them. +## Close it yourself -Record their decision with `wfctl work approve <id> --stage completion`, passing -their own answer through `--attested`. A hand-written receipt fails verification. +Closure asks whether the work matches the framing that was approved, and every +part of that answer is in the record already: verified criteria, passed receipts, +pinned revisions, terminal issues. Do not put it to the maintainer. One night +spent waiting on that question cost seven hours and fifty-four minutes and two of +four approved bundles. -Then finish in this order, because each step invalidates the one before it. -Every semantic edit to `change.md` first. Then `wfctl work checkpoint <id> ---stage review`, before the final hash receipt — a checkpoint edit changes the -file's hash, so a receipt taken ahead of it is stale on arrival. Then re-read +Finish in this order, because each step invalidates the one before it. Every +semantic edit to `change.md` first. Then `wfctl work checkpoint <id> --stage +review`, before the final hash receipt — a checkpoint edit changes the file's +hash, so a receipt taken ahead of it is stale on arrival. Then re-read `change.md` and everything else the review changed, and refresh those receipts. Then: @@ -116,5 +114,40 @@ wfctl work close <id> --outcome completed|partial|abandoned ``` Use the honest outcome. `partial` and `abandoned` are results, and a `completed` -that had to be argued for is the one worth doubting. Closing makes the bundle's -checkpoint terminal; the session state it holds is finished rather than captured. +that had to be argued for is the one worth doubting. + +One refusal here is a real question rather than an errand: delivery no longer +matches the approved framing, because the acceptance criteria were reworded since +they agreed to them, or work left the route as a dropped issue. That is the one +thing at the end they alone can settle. Render it with `wfctl work ask <id> +--stage completion` and record their answer with `wfctl work approve <id> --stage +completion --attested "<what they said>"`. + +## Then ask the one question that is theirs + +A closed bundle holding pages waits in the promotion queue rather than the +archive, and the pages wait with it. + +```sh +wfctl work ask <id> --stage promotion +``` + +That packet is the pages themselves, in full, saying of each whether it replaces +something the project already claims. It is the decision that compounds: a +completion receipt is read by an auditor once, and a page is read first by every +session that touches this part of the project. + +The render is only as honest as what it reads. A page that says nothing is shown +as empty, and a draft still carrying its template's words is shown as written. +Repair the page rather than the packet. + +Their word writes it: + +```sh +wfctl work promote <id> --by human:<maintainer-id> --attested "<what they said>" \ + --session "<where they said it>" +``` + +That copies every page into `knowledge/`, validates them, and archives the +bundle. If validation refuses, nothing is written and the bundle stays in the +queue — so fix the page and ask again rather than leaving the corpus half-taught. diff --git a/.claude/skills/verify-project-work/references/completion-gate.md b/.claude/skills/verify-project-work/references/completion-gate.md index a1cd1fe..9ec1da5 100644 --- a/.claude/skills/verify-project-work/references/completion-gate.md +++ b/.claude/skills/verify-project-work/references/completion-gate.md @@ -16,9 +16,13 @@ A completed record requires: - `implementation_reviewed: true` for code-scoped work, or `knowledge_reviewed: true` for project-only work; - `maintainer_review.framing.status: approved` with a human actor and timestamp; -- `maintainer_review.completion.status: approved` with a human actor and timestamp; -- `knowledge_promotion.status: applied` with validated concept paths, or - `not-needed` with a concrete reason; +- `knowledge_promotion.status: pending` with the pages drafted under the bundle's + `promotion/` directory, `applied` with validated concept paths, or `not-needed` + with a concrete reason; +- delivery that still matches the approved framing. Where the acceptance criteria + have been reworded since the approval, or an issue was dropped from the route, + closure additionally requires `maintainer_review.completion.status: approved` — + it is the one case at the end where what was approved is not what was built; - one or more fresh checks with commands and outcomes; - `verification.result: passed`; - no unresolved item without an explicit accepted disposition; @@ -34,11 +38,15 @@ the changed record and record its final file receipt. A checkpoint edit changes the file hash; recording the receipt before the checkpoint would immediately make that receipt stale. +Nothing here needs the maintainer. Every item is something the record either +carries or does not, which is why closure is the agent's: a person asked to +confirm this list is signing arithmetic they cannot check better than the tool. +What is theirs is the framing before the work and the pages after it. + The CLI validates the bundle graph, file hashes, record structure, exact source -bindings, and any promoted concept files. It -cannot prove that a conversation occurred, that semantic evidence is correct, -or that no material claim was omitted. The agent must record a maintainer -approval only after an explicit decision, and the maintainer remains -responsible for that decision. +bindings, and any promoted concept files. It cannot prove that a conversation +occurred, that semantic evidence is correct, or that no material claim was +omitted. The agent must record a maintainer approval only after an explicit +decision, and the maintainer remains responsible for that decision. If a requirement is intentionally dropped, update scope and record who accepted the change. Do not merely check it off. diff --git a/.workflow/rules/maintainer-review.md b/.workflow/rules/maintainer-review.md index 7f6ba2a..7967f65 100644 --- a/.workflow/rules/maintainer-review.md +++ b/.workflow/rules/maintainer-review.md @@ -11,8 +11,9 @@ Require an explicit maintainer decision before: did not already request that outcome; - implementing a significant spec whose outcome, scope, exclusions, acceptance criteria, or material decisions have not already been explicitly accepted; +- writing anything into `knowledge/`, which is the project speaking about itself; - selecting current truth when evidence cannot resolve chronology or authority; -- accepting material re-scoping, unresolved risk, or a completion claim; +- closing work whose delivery no longer matches the framing they approved; - recording `verified` by a `human:<id>` actor. Do not request review for freezing a clean Git raw scope, file accounting, QMD @@ -82,6 +83,26 @@ Say plainly that "I do not know" is an answer you can record. A recorded gap is worth more than a preference guessed from a polite reply and then treated as settled. +## Find their answer before asking for it again + +Run `wfctl knowledge decided "<subject>"` before putting anything to them. It +reads the four places an answer lands — a promoted decision page, the bundle that +asked the question, a resolved Wayfinder map, and a capture — and reports what it +finds with the date, their own words, and whether a curated page carries it. On a +bundle, `--record <id>` writes the result into the framing, and the framing gate +holds until it has. + +Their answer is usually not on a page. Twenty-two of twenty-six recorded +decisions in one project had never been promoted, so a search of curated +knowledge truthfully found nothing and read exactly like a question nobody had +answered. Work already delivered counts the same way: a completed issue that +recorded a consequence as theirs to settle has answered the question once +already, and asking again spends their turn on their own bookkeeping. + +Cite the page when there is one and the record when there is not, and say which. +A decision reachable only through an archive is one the corpus has not been +taught, and saying so is what eventually teaches it. + ## Re-establish the subject before asking about it A claim is the unit of record. It is not the unit of a question. Recorded @@ -175,13 +196,48 @@ and confirming intent that was are different questions, and asking the harder one when the easier one applies is how a body of accepted work becomes an unanswered queue. -Put the framing decision before implementation, not before closure. `wfctl work -issue claim` refuses a delivery issue whose framing is unapproved, which is the -moment to present it: the bundle has been read, nothing is in flight, and the -maintainer's absence blocks nothing yet. Discovering the same gate at completion -parks finished work behind a decision that could have been made on day one. -Approving edits the change record, so refresh its checkpoint and re-read it -before claiming. +## Where the two gates are, and why closure is not one + +A maintainer decides two things about a piece of work. What it is, before it +starts. What the project says about itself afterwards. Everything between those +is the agent's, including the moment the work finishes. + +**Put the framing decision before implementation.** `wfctl work issue claim` +refuses a delivery issue whose framing is unapproved, which is the moment to +present it: the bundle has been read, nothing is in flight, and the maintainer's +absence blocks nothing yet. Approving edits the change record, so refresh its +checkpoint and re-read it before claiming. + +**Closure is arithmetic, so close it.** Whether the acceptance criteria are met, +the receipts carry evidence, every issue is terminal and the revisions are pinned +is what the completion gate itself checks. A maintainer asked to confirm that is +being asked to sign a sum they cannot check better than the tool, and the cost is +not theoretical: four bundles were framed and approved for one unattended night, +two were delivered in sixty-two minutes and stopped at that gate, the other two +were never started, and seven hours and fifty-four minutes passed before anyone +could say the word. Nothing about those two bundles was in doubt. + +**Closure returns to them when delivery drifted from the framing.** That is the +one case at the end where something is genuinely undecided, because what was +approved is not what was built. Two things raise it, both observable: the +acceptance criteria have been reworded, added to or cut since the approval, or +work left the route as a dropped issue. The tool names which, and the completion +approval it then asks for is the same command it always was — now the exception +rather than the toll. + +**Promotion is the gate that compounds.** Approving a completion writes a receipt +an auditor may read once. Approving a page writes what every future session reads +first and what the next framing is aligned against. Draft the pages under the +bundle's `promotion/` directory before closing, record them with `wfctl work +promotion <id>`, and put them to the maintainer with `wfctl work ask <id> --stage +promotion`, which shows the pages themselves rather than a list of paths. Their +word, through `wfctl work promote <id>`, is what writes them into `knowledge/`. + +A bundle closed with pages waiting sits in the promotion queue instead of the +archive, and nothing running is held by it — the code shipped, the issues are +terminal. What it does hold is the next framing approval in the same Area, on the +ground that aligning new work against knowledge already known to be behind is the +alignment telling them something the project has stopped believing. Approval and permission to start are different decisions and the record holds them separately. A maintainer who approves a framing and says the work is not to @@ -193,20 +249,24 @@ it, and a release is never inferred: not from a truthful answer to an unrelated question, and not from the condition that held it having cleared. The last time one was inferred, six commits landed in three source repositories. -Render both gates with `wfctl work ask <id> [--stage completion]` rather than -composing one. Each carries the four things its decision fixes — a framing: -what gets done, what deliberately does not, what will make it finished, and in -what order; a completion: what the work does now, what it still does not do, -what closing it takes on, and what the project now says that it did not — and -nothing else from a record written for an agent. A section still holding the -shipped template's own words is reported as unwritten rather than read out as -scope. A render is only as honest as the record behind it: repair the record -rather than the packet, because a packet edited by hand is composed again. - -For significant work, record framing and completion decisions with `wfctl work -approve <id> --stage framing|completion --by human:<maintainer-id>`. It writes -both the `maintainer_review` receipt and the durable approval record the -completion gate checks. +Render every gate with `wfctl work ask <id> [--stage promotion|completion]` +rather than composing one. A framing carries what gets done, what deliberately +does not, what will make it finished, and in what order. A promotion carries the +pages themselves, in full, and says of each whether it replaces something the +project already claims. A completion — asked only where delivery drifted — +carries what the work does now, what it still does not do, what closing it takes +on, and what the project now says that it did not. Nothing else from a record +written for an agent reaches any of them. A section still holding the shipped +template's own words is reported as unwritten rather than read out as scope. A +render is only as honest as the record behind it: repair the record rather than +the packet, because a packet edited by hand is composed again. + +Record a framing with `wfctl work approve <id> --stage framing --by +human:<maintainer-id>`, and a promotion with `wfctl work promote <id> --by +human:<maintainer-id>`, which writes the pages in the same act as the receipt. +Both write the `maintainer_review` entry and the durable approval record the +gates check. A framing approval also digests the acceptance criteria it settled, +which is what later tells a reworded contract from the one they agreed to. Pass `--attested "<their answer, word for word>" --session "<where they said it>"`. That is the ordinary path, because the ordinary case is a maintainer who @@ -221,7 +281,8 @@ maintainer's to ask for, never your default. Never hand-write receipt fails verification. Existing explicit maintainer instructions may satisfy the framing decision, but still record it through the command; do not ask for the same decision twice. Reopen the gate when the approved framing -changes materially. +changes materially — and where the acceptance criteria are what changed, the tool +reopens it for you at closure rather than trusting anyone to notice. Approving edits the change record, so re-read it, refresh its review receipt, and refresh the checkpoint afterwards. diff --git a/.workflow/rules/project-work-bundles.md b/.workflow/rules/project-work-bundles.md index f089941..de7c994 100644 --- a/.workflow/rules/project-work-bundles.md +++ b/.workflow/rules/project-work-bundles.md @@ -39,8 +39,11 @@ implementation begins. Before completed closure, enumerate the entire bundle with `wfctl work context --stage review`, re-read every file, refresh stale receipts, reconcile every -stable acceptance ID against direct evidence, promote durable truth separately, -and obtain explicit maintainer completion approval. +stable acceptance ID against direct evidence, and draft the curated pages this +work changes under the bundle's `promotion/` directory. Then record them with +`wfctl work promotion <id>` and close. Closure needs no maintainer: it asks only +whether the work matches the framing they already approved, and the gates answer +that. The pages are what needs them, and they are asked after the bundle closes. A capture is not automatically a chore. When it holds something only the maintainer can settle, create it with `--awaits maintainer` so it appears in diff --git a/.workflow/rules/project-work-lifecycle.md b/.workflow/rules/project-work-lifecycle.md index fececff..376aade 100644 --- a/.workflow/rules/project-work-lifecycle.md +++ b/.workflow/rules/project-work-lifecycle.md @@ -27,13 +27,15 @@ repository owns significant-work bundles and current project knowledge. 10. Preserve implementation in the exact clean Git commit only with normal maintainer authorization. 11. Run `verify-project-work` across the whole bundle and every bound source - revision, obtain completion approval, then promote verified durable truth - and close honestly. That order is not a preference: a curated page cites the - change as its authority, and that citation resolves only once the change is - receipt-ready, which requires the recorded completion approval. Drafting the - pages earlier is useful and costs nothing; writing them into `knowledge/` - earlier leaves the corpus invalid so that progress can look further along - than it is. + revision, draft the curated pages under the bundle's `promotion/` directory, + record them with `wfctl work promotion <id>`, and close. None of that needs + the maintainer: closure is what the gates already prove, and they prove it + better than a person reading a summary of it at midnight. +12. Put the pages to them with `wfctl work ask <id> --stage promotion` and write + them with `wfctl work promote <id>` once they answer. Until then the closed + bundle waits in the promotion queue rather than the archive, and the pages + wait with it. `knowledge/` never holds a page nobody approved, and the corpus + is never left invalid so that progress can look further along than it is. On a clean session or resume, run `wfctl work context --stage resume` without an ID. It may auto-select only one bound active record; multiple records require a diff --git a/.workflow/state.json b/.workflow/state.json index 366b6ab..e4009b4 100644 --- a/.workflow/state.json +++ b/.workflow/state.json @@ -10,13 +10,13 @@ "sha256": "5e77ad0c14faed7eb6661fb69f6ce4f118d3800da4ba6797c986b45714d20b3d" }, ".claude/rules/maintainer-review.md": { - "sha256": "e98a4f05735d48ef98fb3033267ecdb4dd5346c1f4d35af29adaaff624f9bbc1" + "sha256": "ee12e298ba4ee760e31c44f59447c9cd2d1bc2db075cdc6a3952cdc54cdd6939" }, ".claude/rules/project-work-bundles.md": { - "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" + "sha256": "72c0f39dfd4f7d4d3eac35ddc8edfb6b5d6df96bea6fa97a14b3cc4cd835ced1" }, ".claude/rules/project-work-lifecycle.md": { - "sha256": "dcc9cdf70013226c30420589ad409b3b8f01106c76e78d09187565ab66606dc5" + "sha256": "bf40b682d2dbf063e4fbecff7cb7dff13fabedfce4817492f39087060200ae52" }, ".claude/rules/workflow-routing.md": { "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" @@ -31,13 +31,13 @@ "sha256": "5e77ad0c14faed7eb6661fb69f6ce4f118d3800da4ba6797c986b45714d20b3d" }, ".workflow/rules/maintainer-review.md": { - "sha256": "e98a4f05735d48ef98fb3033267ecdb4dd5346c1f4d35af29adaaff624f9bbc1" + "sha256": "ee12e298ba4ee760e31c44f59447c9cd2d1bc2db075cdc6a3952cdc54cdd6939" }, ".workflow/rules/project-work-bundles.md": { - "sha256": "7746d3f2426cd7eb44ac9a232f6c2bc135176613f2a2a12e0a6ae6605f3db2e0" + "sha256": "72c0f39dfd4f7d4d3eac35ddc8edfb6b5d6df96bea6fa97a14b3cc4cd835ced1" }, ".workflow/rules/project-work-lifecycle.md": { - "sha256": "dcc9cdf70013226c30420589ad409b3b8f01106c76e78d09187565ab66606dc5" + "sha256": "bf40b682d2dbf063e4fbecff7cb7dff13fabedfce4817492f39087060200ae52" }, ".workflow/rules/workflow-routing.md": { "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" diff --git a/AGENTS.md b/AGENTS.md index 94b59f6..c63f21e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,21 +24,41 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und a different question, and never from the reconstruction or blocker that held it having since cleared: the condition ending is not the same as being told to go. -- Put both gates to the maintainer with `wfctl work ask`, which renders the four - things each decision fixes and nothing else from a record written for you. A - framing: what gets done, what deliberately does not, what makes it finished, in - what order. A completion, with `--stage completion`: what the work does now, - what it still does not do, what closing it takes on, what the project now says - that it did not. When a render reads wrong, repair the record it read; a packet - edited by hand is composed again, and composed is what put file paths and - criterion ids in front of them. -- Record framing and completion approvals with `wfctl work approve`, never by - editing `maintainer_review`; a hand-written receipt fails verification. Pass - `--attested "<their answer, word for word>" --session "<where they said it>"` - when they answered in the session, which is the ordinary case. Do not send them - to a second terminal: retyping a generated bundle id, a stage name and their - own identity records no decision the attestation does not. A typed confirmation - or `--token` remains available and is theirs to ask for, never your default. +- Ask the maintainer two things, and closure is neither of them. A framing, with + `wfctl work ask <id>`: what gets done, what deliberately does not, what makes it + finished, in what order. A promotion, with `--stage promotion`: the pages this + work would write into curated knowledge, in full, and what each replaces. Both + are rendered from the record rather than composed; when a render reads wrong, + repair the record it read, because a packet edited by hand is composed again + and composed is what put file paths and criterion ids in front of them. +- Close finished work yourself. Whether the criteria are met, the receipts carry + evidence and the revisions are pinned is what the gates already check, and + asking the maintainer to confirm arithmetic is not a decision. One night this + cost seven hours and fifty-four minutes: two of four approved bundles were + delivered in an hour, stopped at a gate only a sleeping person could open, and + the other two were never started. Closure returns to them in exactly one case, + and the tool names it: delivery no longer matches the framing they approved, + because the criteria were reworded or work was dropped from the route. +- Draft the curated pages before closing, under the bundle's `promotion/` + directory, at the path each will occupy inside `knowledge/`. Then run `wfctl + work promotion <id>`, which records them from what is on disk, or `--none + "<why>"` when this work changes nothing the project says about itself. A closed + bundle holding pages waits in the promotion queue rather than archiving, and + `wfctl work promote <id>` writes them on the maintainer's word. +- Record approvals with the commands, never by editing `maintainer_review`; a + hand-written receipt fails verification. Pass `--attested "<their answer, word + for word>" --session "<where they said it>"` when they answered in the session, + which is the ordinary case. Do not send them to a second terminal: retyping a + generated bundle id, a stage name and their own identity records no decision + the attestation does not. A typed confirmation or `--token` remains available + and is theirs to ask for, never your default. +- Run `wfctl knowledge decided "<subject>"` before putting any question to the + maintainer, whatever route you are on. It reads the four places an answer lands + — a promoted page, the bundle that asked, a resolved map, a capture — plus work + already delivered, and reports the date and their own words. Most answers are + not on a page, so a search of curated knowledge alone finds nothing and reads + like a question nobody has answered. On a bundle, `--record <id>` writes the + result into the framing, which the framing gate requires. - Ask one material question at a time, include a recommendation, and update the durable record before continuing. - Write to a maintainer who was not watching. They did not see the tool calls, diff --git a/PROJECT_WORKFLOW.md b/PROJECT_WORKFLOW.md index 0dcd5cf..d246ee2 100644 --- a/PROJECT_WORKFLOW.md +++ b/PROJECT_WORKFLOW.md @@ -234,18 +234,25 @@ map or creates a parallel strategy source. chronology, or which source governs. 4. **Knowledge** — material claims about vision, product meaning, architecture, ownership, contracts, policy, decisions, supersession, or accepted risk. -5. **Completion** — acceptance results, directly inspected implementation, - fresh checks, deviations, risks, and the knowledge delta or no-update reason. +5. **Promotion** — the pages the work would write into `knowledge/`, in full, + and what each of them replaces. + +You are not asked whether finished work is finished. Whether the criteria are +met, the receipts carry evidence and the revisions are pinned is what the tool +checks, and it checks it better than a summary of it can. The exception is +delivery that no longer matches the framing you approved — reworded criteria, or +work dropped from the route — and there the agent comes back to you. An approval is explicit. Silence and continued conversation are not approval. You never edit YAML manually. -Framing and completion approvals for significant work are recorded by you, with -`wfctl work approve <change-id> --stage framing|completion --by -human:<your-id>`. The command prints the decision and waits for you to type -`approve`; the agent can prepare it but cannot answer that prompt, and a -receipt written into the record by hand fails verification. Automation may -substitute `--token` matching a `WFCTL_APPROVAL_TOKEN` you set out of band. +Approvals are recorded by you: `wfctl work approve <change-id> --stage framing +--by human:<your-id>` for a framing, and `wfctl work promote <change-id> --by +human:<your-id>` for the pages, which writes them in the same act. Ordinarily the +agent records the answer you already gave in conversation, word for word. You can +ask instead for a typed confirmation, or for automation to use a `--token` +matching a `WFCTL_APPROVAL_TOKEN` you set out of band; a receipt written into the +record by hand fails verification either way. This records provenance, not identity. It shows the approval came from a deliberate separate step, not that a specific person typed it. Every other @@ -289,16 +296,18 @@ the living record before continuing. worktree receipt per repository. 13. Enumerate and read the complete bundle, refresh every content-hash receipt, and reject unseen, changed, malformed, or silently dropped work. -14. Promote durable verified truth into `knowledge/`, or record why no current - knowledge changed. -15. Obtain completion approval, mark the record completion-ready, and compute - current content hashes for promoted stable concepts. -16. Run `wfctl knowledge validate --target <Knowledge root>` for promoted - concepts. -17. Run `wfctl knowledge build --target <Knowledge root>` to prove links, +14. Draft the pages this work changes under the bundle's `promotion/` directory, + at the path each will occupy inside `knowledge/`, and record them with + `wfctl work promotion <change-id>` — or `--none "<why>"` when nothing the + project says about itself changed. +15. Run `wfctl knowledge build --target <Knowledge root>` to prove links, authored relationships, and stable-concept reachability. -18. Run `wfctl work verify`, and archive the honest - outcome with `wfctl work close`. +16. Run `wfctl work verify`, and archive the honest outcome with + `wfctl work close`. A bundle holding pages waits in the promotion queue + rather than the archive. +17. Put the pages to you with `wfctl work ask <change-id> --stage promotion`, and + write them on your word with `wfctl work promote <change-id>`. That copies + them into `knowledge/`, validates them, and archives the bundle. A material turn may come from discussion or investigation. It changes a requirement, constraint, alternative, decision, scope, evidence, risk, diff --git a/skills-lock.json b/skills-lock.json index 7c04945..f2a40dd 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -4,7 +4,7 @@ "align-project-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "4b16b699280863a9a8cb0d0a0fda3abf77ab150024920293b79b29c932fc7f42" + "computedHash": "556e5652ba061dd4220353769a02b67e4edb0d2b929208177523a3b826bd54f1" }, "analyze-with-graphify": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", @@ -39,7 +39,7 @@ "manage-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "3c79d2aad3bd880c53b7b4026abf8f229e5b0038a0745ef5b1dfb8586cdc1e4d" + "computedHash": "1f33177bfcb13423c56bc9b7a6a4b1dac8d28b653a776847d785183bca10e93d" }, "qmd": { "source": "/Users/lukachisama/.bun/install/global/node_modules/@tobilu/qmd/skills/qmd", @@ -74,7 +74,7 @@ "verify-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "569e4109926873eaeecb16141f1f6ba0b947c8b9dafca0e63fc1abb7d0f540dd" + "computedHash": "335b19c9782d95f72ef5c6c07f66a4a8c814f9242c79f143cb0660f3685795a3" } } } From d038ff16fedd425c5887c641901c08b6a864f5f0 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 14:23:04 +0300 Subject: [PATCH 102/124] =?UTF-8?q?chore(workflow):=20wfctl=20upgrade=20?= =?UTF-8?q?=E2=80=94=20promotion=20gate,=20handoff=20checkpoints,=20decide?= =?UTF-8?q?d=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../curate-engineering-knowledge/SKILL.md | 6 +++ .../skills/curate-product-knowledge/SKILL.md | 6 +++ .../skills/curate-project-knowledge/SKILL.md | 33 ++++++++++++++--- .agents/skills/implement-work-item/SKILL.md | 6 ++- .agents/skills/manage-project-work/SKILL.md | 2 + .../skills/verify-knowledge-quality/SKILL.md | 8 +++- .agents/skills/verify-project-work/SKILL.md | 3 +- .claude/rules/execution-continuity.md | 27 ++++++++++++-- .claude/rules/maintainer-review.md | 25 +++++++++++++ .claude/rules/project-work-bundles.md | 6 +++ .claude/rules/project-work-lifecycle.md | 2 + .../curate-engineering-knowledge/SKILL.md | 6 +++ .../skills/curate-product-knowledge/SKILL.md | 6 +++ .../skills/curate-project-knowledge/SKILL.md | 33 ++++++++++++++--- .claude/skills/implement-work-item/SKILL.md | 6 ++- .claude/skills/manage-project-work/SKILL.md | 2 + .../skills/verify-knowledge-quality/SKILL.md | 8 +++- .claude/skills/verify-project-work/SKILL.md | 3 +- .workflow/rules/execution-continuity.md | 27 ++++++++++++-- .workflow/rules/maintainer-review.md | 25 +++++++++++++ .workflow/rules/project-work-bundles.md | 6 +++ .workflow/rules/project-work-lifecycle.md | 2 + .workflow/runtime/guard-stop.mjs | 30 ++++++++++----- .workflow/state.json | 18 ++++----- AGENTS.md | 37 +++++++++++++++---- PROJECT_WORKFLOW.md | 14 ++++--- skills-lock.json | 14 +++---- 27 files changed, 292 insertions(+), 69 deletions(-) diff --git a/.agents/skills/curate-engineering-knowledge/SKILL.md b/.agents/skills/curate-engineering-knowledge/SKILL.md index 7f4a82a..3bc65ea 100644 --- a/.agents/skills/curate-engineering-knowledge/SKILL.md +++ b/.agents/skills/curate-engineering-knowledge/SKILL.md @@ -44,6 +44,12 @@ document. ownership at `knowledge/repositories/`, and Area-owned implementation at `knowledge/areas/<area>/implementation/`. +The path a page occupies is where it will live, whichever route it takes to get +there. Work from a change bundle writes it under that bundle's `promotion/` +directory at exactly that path, and the maintainer's word is what copies it into +`knowledge/`. Work from a reconstruction or intake case writes it into +`knowledge/` directly, because that promotion is what its closure waits for. + ## Verify before stable 1. Invoke `verify-knowledge-quality` after the body and evidence are complete. diff --git a/.agents/skills/curate-product-knowledge/SKILL.md b/.agents/skills/curate-product-knowledge/SKILL.md index 1753516..58a0062 100644 --- a/.agents/skills/curate-product-knowledge/SKILL.md +++ b/.agents/skills/curate-product-knowledge/SKILL.md @@ -71,6 +71,12 @@ product-first and bounded: - show delivery honestly; - keep `Engineering details` as links with short nontechnical labels. +The path a page occupies is where it will live, whichever route it takes to get +there. Work from a change bundle writes it under that bundle's `promotion/` +directory at exactly that path, and the maintainer's word is what copies it into +`knowledge/`. Work from a reconstruction or intake case writes it into +`knowledge/` directly, because that promotion is what its closure waits for. + ## Verify before stable 1. Invoke `verify-knowledge-quality` after the substantive body is complete. diff --git a/.agents/skills/curate-project-knowledge/SKILL.md b/.agents/skills/curate-project-knowledge/SKILL.md index 50efa67..5fa7d33 100644 --- a/.agents/skills/curate-project-knowledge/SKILL.md +++ b/.agents/skills/curate-project-knowledge/SKILL.md @@ -15,7 +15,8 @@ promotion, a new Area, or decision migration. A promotion may start from: -- a completed and maintainer-reviewed archived change; +- a closed change, whose pages are written under its own `promotion/` directory + and enter `knowledge/` only when the maintainer approves them; - confirmed raw-intake candidate IDs with independent authority; - confirmed source-first reconstruction candidate IDs; - directly inspected source and tests at an exact Git revision; @@ -79,11 +80,31 @@ for symmetry. concept. Do not self-approve a failed, uncertain, unread, or blocked check. 13. Finish content before hashing. Bind the passed quality receipt and normal verification to the same `wfctl knowledge hash --concept <path>` output. - Normative claims require human verification. -14. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. - Rebuild embeddings only when semantic retrieval is needed. -15. Return to the originating intake, reconstruction, or change workflow and - record exact promoted paths. Do not report completion while any gate fails. + Normative claims require human verification. The hash reads frontmatter and + body rather than location, so a page drafted under a bundle's `promotion/` + directory is sealed where it is and the seal survives the copy. +14. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update` for + a page that is already in `knowledge/`. Rebuild embeddings only when semantic + retrieval is needed. +15. Return to the originating workflow and record where each page is. A change + bundle keeps its pages under `promotion/` and records them with `wfctl work + promotion <id>`; nothing it wrote is in the corpus, and saying it is promoted + would claim a decision the maintainer has not made. Do not report completion + while any gate fails. + +## Where a page goes before it is knowledge + +Writing into `knowledge/` is the project speaking about itself, and it is a +maintainer decision. Two routes reach it, and they differ: + +| Source | Where the page is written | What puts it in `knowledge/` | +| --- | --- | --- | +| Change bundle | `changes/<state>/<id>/promotion/<destination>` | `wfctl work promote <id>`, on the maintainer's word | +| Reconstruction or intake case | `knowledge/` directly | the case's own promotion, which its closure is waiting for | + +The destination path is the same either way: write the draft at exactly the path +it will occupy, because that is the path it is copied to and the path every link +in it must resolve against. ## Authority rules diff --git a/.agents/skills/implement-work-item/SKILL.md b/.agents/skills/implement-work-item/SKILL.md index 01b7a93..c6b04f1 100644 --- a/.agents/skills/implement-work-item/SKILL.md +++ b/.agents/skills/implement-work-item/SKILL.md @@ -100,5 +100,7 @@ does not infer another issue, actor, checkout, or code root. If deliberately giving the issue back, run `wfctl work issue release`; it resets the issue checkpoint to ready. Completion makes the issue checkpoint terminal; then refresh the parent checkpoint with the next frontier action. Do not mark a -partial outcome completed. Final change-wide review, knowledge promotion, and -archival belong to `verify-project-work` after every required issue is terminal. +partial outcome completed. Final change-wide review, drafting the pages this work +changes, and closure belong to `verify-project-work` after every required issue +is terminal; the maintainer's approval of those pages comes after the bundle is +closed, and holds nothing up. diff --git a/.agents/skills/manage-project-work/SKILL.md b/.agents/skills/manage-project-work/SKILL.md index 0bbc66c..feb2a9d 100644 --- a/.agents/skills/manage-project-work/SKILL.md +++ b/.agents/skills/manage-project-work/SKILL.md @@ -40,6 +40,8 @@ wfctl work start <slug> --title "<title>" --mode full|slice|wayfinder Start from one leaf for single-repository delivery. Start from knowledge with no leaf for project-only work, or repeat `--leaf` for known multi-repository scope. Do not bind guessed leaves merely because Wayfinder may need them later. +Work that turns out to need one gains it later with `wfctl work bind <id>`, run +from that repository's own checkout. Run `wfctl work status <id>` and `wfctl work context <id> --stage shape`. Use the returned bundle in knowledge for records and only the returned code diff --git a/.agents/skills/verify-knowledge-quality/SKILL.md b/.agents/skills/verify-knowledge-quality/SKILL.md index 2d7e779..1e445f0 100644 --- a/.agents/skills/verify-knowledge-quality/SKILL.md +++ b/.agents/skills/verify-knowledge-quality/SKILL.md @@ -23,9 +23,13 @@ in a session. evidence. 4. Run `wfctl knowledge validate --concept <path>` and distinguish structural failures from semantic failures. Structural success is necessary but never - sufficient. + sufficient. A page still drafted under a bundle's `promotion/` directory has + no corpus position yet, so its structural validation runs when it is + promoted; a refusal there writes nothing and leaves the page where it is. 5. Run `wfctl knowledge hash --concept <path>` and pin the candidate content - hash before semantic review. + hash before semantic review. The path may be a draft: the hash reads + frontmatter and body, never location, and a promoted page is copied byte for + byte, so a seal bound to the draft still matches once it lands. ## Run two independent axes diff --git a/.agents/skills/verify-project-work/SKILL.md b/.agents/skills/verify-project-work/SKILL.md index dbc0e3a..fdf6924 100644 --- a/.agents/skills/verify-project-work/SKILL.md +++ b/.agents/skills/verify-project-work/SKILL.md @@ -72,7 +72,8 @@ Decide whether verified durable truth changed, and write the pages while the understanding is fresh. Route product behaviour and engineering realization through their separate curation skills and run the two-axis quality gate — but write each page under the bundle's `promotion/` directory, at the exact path it -will occupy inside `knowledge/`. Nothing enters the corpus here. +will occupy inside `knowledge/` — spelled with or without a leading `knowledge/`, +which name the same page. Nothing enters the corpus here. A concept promoted from a bundle in a project with no reconstructed baseline has the same shape as one established by whole-project reading and a far narrower diff --git a/.claude/rules/execution-continuity.md b/.claude/rules/execution-continuity.md index e87571c..3cd2559 100644 --- a/.claude/rules/execution-continuity.md +++ b/.claude/rules/execution-continuity.md @@ -33,10 +33,29 @@ wrote. Announcing a next action and stopping is the visible half of this; the larger half announces nothing — "the work continues by itself", "the rest can wait for the next boundary", a status report that names no blocker — and parks just as completely, because nothing continues once the turn is over. If you are -not waiting on the maintainer, take the next action you can take alone. If you -are, name what you need from them **and record it as a blocker on the owning -checkpoint**. Only that changes what the repository reports, so only that stops -you being returned to a turn whose one missing piece is a person. +not waiting on the maintainer, take the next action you can take alone. + +**Finishing a unit is not finishing.** Completing an issue releases its claim, +so the bundle is left holding ready issues that nobody has claimed — which is +what every long run looks like between units, and the moment a turn is most +likely to end on "next I will do X" and then not. There is no boundary there. +The next unit is available work, and available work is yours. + +**Say why you are stopping, in the record, when you stop.** Prose is not state: +a turn that explains itself and ends has explained itself to nobody, because the +explanation goes with the session. Two sentences end a turn, and they are +different sentences. + +- The maintainer is what the work is missing. Record a blocker on the owning + checkpoint — `--status blocked --blocker "<what you need from them>"` — which + puts the work in their queue and takes it off yours. +- Nothing is missing except this session: the context is spent, or the next unit + will not fit in what is left of it. Record `--handoff "<why this session stops + here>"`, which tells the next session and asks them nothing. It is cleared by + the next checkpoint, so it explains one stop rather than every stop after it. + +A blocker for the second case is a lie that costs them a turn, and silence for +either is what the workflow returns you to. **Never wait for a background command by spinning.** A command that outruns its foreground limit is moved to the background and announces its own completion; diff --git a/.claude/rules/maintainer-review.md b/.claude/rules/maintainer-review.md index 7967f65..ee9f2d1 100644 --- a/.claude/rules/maintainer-review.md +++ b/.claude/rules/maintainer-review.md @@ -83,6 +83,26 @@ Say plainly that "I do not know" is an answer you can record. A recorded gap is worth more than a preference guessed from a polite reply and then treated as settled. +**Write their reply before you send the message.** Draft the answer you expect, +in their voice, one line. If nothing fits — if the only reply available is +"which one?" or "what specifically?" — then what you wrote is a status line and +the question is still missing. + +Saying that something waits is the common way to fail this, and it fails because +it describes your queue rather than their decision. "Three records are ready and +all three are waiting on you" passes every other rule here: it names no count +alone, it can carry a table, it reads as brevity. It still leaves them to work +out what they are being asked. So does a category — "the boundaries", "a decision +about the deck", "your word on the shape" — which names the subject and withholds +the question. What passes is an alternative with its consequence: "delete the +twenty-two explanations, or give the row a note field; the note is my +recommendation, and it costs one migration." + +This is a test rather than a list of phrases to avoid, because the failure is the +same in every language and rewords faster than any list can be kept. Apply it to +each sentence that puts something to them, including a blocker, a status line, +and the last line of a report. + ## Find their answer before asking for it again Run `wfctl knowledge decided "<subject>"` before putting anything to them. It @@ -239,6 +259,11 @@ terminal. What it does hold is the next framing approval in the same Area, on th ground that aligning new work against knowledge already known to be behind is the alignment telling them something the project has stopped believing. +The packet ends by telling them a wrong page gets rewritten rather than argued +for, and that is a working sentence: rewrite the draft where it sits in the +queue, reseal it, refresh its review receipt, and run `wfctl work promotion <id>` +again. Their answer is never lost by correcting what it was about. + Approval and permission to start are different decisions and the record holds them separately. A maintainer who approves a framing and says the work is not to begin yet — including one approving only so the bundle stops cluttering their diff --git a/.claude/rules/project-work-bundles.md b/.claude/rules/project-work-bundles.md index de7c994..3a8cf30 100644 --- a/.claude/rules/project-work-bundles.md +++ b/.claude/rules/project-work-bundles.md @@ -33,6 +33,12 @@ Before claiming an issue, record a current review receipt for every required context file. Claim from the exact bound leaf before code work. Never infer a worktree from repository name, branch, sibling paths, or bundle location. +A bundle started from the centre without naming a leaf binds no source +repository, and delivery has nowhere to happen. Give it one from that +repository's own checkout with `wfctl work bind <id>`; `wfctl work rebind` moves +a binding the record already carries and is the wrong verb here. Neither is a +maintainer decision. + Wayfinder is deliberate and planning-only. It resolves precise question issues and fog into a reviewed specification before any delivery issue or product-code implementation begins. diff --git a/.claude/rules/project-work-lifecycle.md b/.claude/rules/project-work-lifecycle.md index 376aade..5849c8b 100644 --- a/.claude/rules/project-work-lifecycle.md +++ b/.claude/rules/project-work-lifecycle.md @@ -36,6 +36,8 @@ repository owns significant-work bundles and current project knowledge. bundle waits in the promotion queue rather than the archive, and the pages wait with it. `knowledge/` never holds a page nobody approved, and the corpus is never left invalid so that progress can look further along than it is. + A page they send back is rewritten where it sits, resealed, its receipt + refreshed, and recorded again with `wfctl work promotion <id>`. On a clean session or resume, run `wfctl work context --stage resume` without an ID. It may auto-select only one bound active record; multiple records require a diff --git a/.claude/skills/curate-engineering-knowledge/SKILL.md b/.claude/skills/curate-engineering-knowledge/SKILL.md index 7f4a82a..3bc65ea 100644 --- a/.claude/skills/curate-engineering-knowledge/SKILL.md +++ b/.claude/skills/curate-engineering-knowledge/SKILL.md @@ -44,6 +44,12 @@ document. ownership at `knowledge/repositories/`, and Area-owned implementation at `knowledge/areas/<area>/implementation/`. +The path a page occupies is where it will live, whichever route it takes to get +there. Work from a change bundle writes it under that bundle's `promotion/` +directory at exactly that path, and the maintainer's word is what copies it into +`knowledge/`. Work from a reconstruction or intake case writes it into +`knowledge/` directly, because that promotion is what its closure waits for. + ## Verify before stable 1. Invoke `verify-knowledge-quality` after the body and evidence are complete. diff --git a/.claude/skills/curate-product-knowledge/SKILL.md b/.claude/skills/curate-product-knowledge/SKILL.md index 1753516..58a0062 100644 --- a/.claude/skills/curate-product-knowledge/SKILL.md +++ b/.claude/skills/curate-product-knowledge/SKILL.md @@ -71,6 +71,12 @@ product-first and bounded: - show delivery honestly; - keep `Engineering details` as links with short nontechnical labels. +The path a page occupies is where it will live, whichever route it takes to get +there. Work from a change bundle writes it under that bundle's `promotion/` +directory at exactly that path, and the maintainer's word is what copies it into +`knowledge/`. Work from a reconstruction or intake case writes it into +`knowledge/` directly, because that promotion is what its closure waits for. + ## Verify before stable 1. Invoke `verify-knowledge-quality` after the substantive body is complete. diff --git a/.claude/skills/curate-project-knowledge/SKILL.md b/.claude/skills/curate-project-knowledge/SKILL.md index 50efa67..5fa7d33 100644 --- a/.claude/skills/curate-project-knowledge/SKILL.md +++ b/.claude/skills/curate-project-knowledge/SKILL.md @@ -15,7 +15,8 @@ promotion, a new Area, or decision migration. A promotion may start from: -- a completed and maintainer-reviewed archived change; +- a closed change, whose pages are written under its own `promotion/` directory + and enter `knowledge/` only when the maintainer approves them; - confirmed raw-intake candidate IDs with independent authority; - confirmed source-first reconstruction candidate IDs; - directly inspected source and tests at an exact Git revision; @@ -79,11 +80,31 @@ for symmetry. concept. Do not self-approve a failed, uncertain, unread, or blocked check. 13. Finish content before hashing. Bind the passed quality receipt and normal verification to the same `wfctl knowledge hash --concept <path>` output. - Normative claims require human verification. -14. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. - Rebuild embeddings only when semantic retrieval is needed. -15. Return to the originating intake, reconstruction, or change workflow and - record exact promoted paths. Do not report completion while any gate fails. + Normative claims require human verification. The hash reads frontmatter and + body rather than location, so a page drafted under a bundle's `promotion/` + directory is sealed where it is and the seal survives the copy. +14. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update` for + a page that is already in `knowledge/`. Rebuild embeddings only when semantic + retrieval is needed. +15. Return to the originating workflow and record where each page is. A change + bundle keeps its pages under `promotion/` and records them with `wfctl work + promotion <id>`; nothing it wrote is in the corpus, and saying it is promoted + would claim a decision the maintainer has not made. Do not report completion + while any gate fails. + +## Where a page goes before it is knowledge + +Writing into `knowledge/` is the project speaking about itself, and it is a +maintainer decision. Two routes reach it, and they differ: + +| Source | Where the page is written | What puts it in `knowledge/` | +| --- | --- | --- | +| Change bundle | `changes/<state>/<id>/promotion/<destination>` | `wfctl work promote <id>`, on the maintainer's word | +| Reconstruction or intake case | `knowledge/` directly | the case's own promotion, which its closure is waiting for | + +The destination path is the same either way: write the draft at exactly the path +it will occupy, because that is the path it is copied to and the path every link +in it must resolve against. ## Authority rules diff --git a/.claude/skills/implement-work-item/SKILL.md b/.claude/skills/implement-work-item/SKILL.md index 01b7a93..c6b04f1 100644 --- a/.claude/skills/implement-work-item/SKILL.md +++ b/.claude/skills/implement-work-item/SKILL.md @@ -100,5 +100,7 @@ does not infer another issue, actor, checkout, or code root. If deliberately giving the issue back, run `wfctl work issue release`; it resets the issue checkpoint to ready. Completion makes the issue checkpoint terminal; then refresh the parent checkpoint with the next frontier action. Do not mark a -partial outcome completed. Final change-wide review, knowledge promotion, and -archival belong to `verify-project-work` after every required issue is terminal. +partial outcome completed. Final change-wide review, drafting the pages this work +changes, and closure belong to `verify-project-work` after every required issue +is terminal; the maintainer's approval of those pages comes after the bundle is +closed, and holds nothing up. diff --git a/.claude/skills/manage-project-work/SKILL.md b/.claude/skills/manage-project-work/SKILL.md index 0bbc66c..feb2a9d 100644 --- a/.claude/skills/manage-project-work/SKILL.md +++ b/.claude/skills/manage-project-work/SKILL.md @@ -40,6 +40,8 @@ wfctl work start <slug> --title "<title>" --mode full|slice|wayfinder Start from one leaf for single-repository delivery. Start from knowledge with no leaf for project-only work, or repeat `--leaf` for known multi-repository scope. Do not bind guessed leaves merely because Wayfinder may need them later. +Work that turns out to need one gains it later with `wfctl work bind <id>`, run +from that repository's own checkout. Run `wfctl work status <id>` and `wfctl work context <id> --stage shape`. Use the returned bundle in knowledge for records and only the returned code diff --git a/.claude/skills/verify-knowledge-quality/SKILL.md b/.claude/skills/verify-knowledge-quality/SKILL.md index 2d7e779..1e445f0 100644 --- a/.claude/skills/verify-knowledge-quality/SKILL.md +++ b/.claude/skills/verify-knowledge-quality/SKILL.md @@ -23,9 +23,13 @@ in a session. evidence. 4. Run `wfctl knowledge validate --concept <path>` and distinguish structural failures from semantic failures. Structural success is necessary but never - sufficient. + sufficient. A page still drafted under a bundle's `promotion/` directory has + no corpus position yet, so its structural validation runs when it is + promoted; a refusal there writes nothing and leaves the page where it is. 5. Run `wfctl knowledge hash --concept <path>` and pin the candidate content - hash before semantic review. + hash before semantic review. The path may be a draft: the hash reads + frontmatter and body, never location, and a promoted page is copied byte for + byte, so a seal bound to the draft still matches once it lands. ## Run two independent axes diff --git a/.claude/skills/verify-project-work/SKILL.md b/.claude/skills/verify-project-work/SKILL.md index dbc0e3a..fdf6924 100644 --- a/.claude/skills/verify-project-work/SKILL.md +++ b/.claude/skills/verify-project-work/SKILL.md @@ -72,7 +72,8 @@ Decide whether verified durable truth changed, and write the pages while the understanding is fresh. Route product behaviour and engineering realization through their separate curation skills and run the two-axis quality gate — but write each page under the bundle's `promotion/` directory, at the exact path it -will occupy inside `knowledge/`. Nothing enters the corpus here. +will occupy inside `knowledge/` — spelled with or without a leading `knowledge/`, +which name the same page. Nothing enters the corpus here. A concept promoted from a bundle in a project with no reconstructed baseline has the same shape as one established by whole-project reading and a far narrower diff --git a/.workflow/rules/execution-continuity.md b/.workflow/rules/execution-continuity.md index e87571c..3cd2559 100644 --- a/.workflow/rules/execution-continuity.md +++ b/.workflow/rules/execution-continuity.md @@ -33,10 +33,29 @@ wrote. Announcing a next action and stopping is the visible half of this; the larger half announces nothing — "the work continues by itself", "the rest can wait for the next boundary", a status report that names no blocker — and parks just as completely, because nothing continues once the turn is over. If you are -not waiting on the maintainer, take the next action you can take alone. If you -are, name what you need from them **and record it as a blocker on the owning -checkpoint**. Only that changes what the repository reports, so only that stops -you being returned to a turn whose one missing piece is a person. +not waiting on the maintainer, take the next action you can take alone. + +**Finishing a unit is not finishing.** Completing an issue releases its claim, +so the bundle is left holding ready issues that nobody has claimed — which is +what every long run looks like between units, and the moment a turn is most +likely to end on "next I will do X" and then not. There is no boundary there. +The next unit is available work, and available work is yours. + +**Say why you are stopping, in the record, when you stop.** Prose is not state: +a turn that explains itself and ends has explained itself to nobody, because the +explanation goes with the session. Two sentences end a turn, and they are +different sentences. + +- The maintainer is what the work is missing. Record a blocker on the owning + checkpoint — `--status blocked --blocker "<what you need from them>"` — which + puts the work in their queue and takes it off yours. +- Nothing is missing except this session: the context is spent, or the next unit + will not fit in what is left of it. Record `--handoff "<why this session stops + here>"`, which tells the next session and asks them nothing. It is cleared by + the next checkpoint, so it explains one stop rather than every stop after it. + +A blocker for the second case is a lie that costs them a turn, and silence for +either is what the workflow returns you to. **Never wait for a background command by spinning.** A command that outruns its foreground limit is moved to the background and announces its own completion; diff --git a/.workflow/rules/maintainer-review.md b/.workflow/rules/maintainer-review.md index 7967f65..ee9f2d1 100644 --- a/.workflow/rules/maintainer-review.md +++ b/.workflow/rules/maintainer-review.md @@ -83,6 +83,26 @@ Say plainly that "I do not know" is an answer you can record. A recorded gap is worth more than a preference guessed from a polite reply and then treated as settled. +**Write their reply before you send the message.** Draft the answer you expect, +in their voice, one line. If nothing fits — if the only reply available is +"which one?" or "what specifically?" — then what you wrote is a status line and +the question is still missing. + +Saying that something waits is the common way to fail this, and it fails because +it describes your queue rather than their decision. "Three records are ready and +all three are waiting on you" passes every other rule here: it names no count +alone, it can carry a table, it reads as brevity. It still leaves them to work +out what they are being asked. So does a category — "the boundaries", "a decision +about the deck", "your word on the shape" — which names the subject and withholds +the question. What passes is an alternative with its consequence: "delete the +twenty-two explanations, or give the row a note field; the note is my +recommendation, and it costs one migration." + +This is a test rather than a list of phrases to avoid, because the failure is the +same in every language and rewords faster than any list can be kept. Apply it to +each sentence that puts something to them, including a blocker, a status line, +and the last line of a report. + ## Find their answer before asking for it again Run `wfctl knowledge decided "<subject>"` before putting anything to them. It @@ -239,6 +259,11 @@ terminal. What it does hold is the next framing approval in the same Area, on th ground that aligning new work against knowledge already known to be behind is the alignment telling them something the project has stopped believing. +The packet ends by telling them a wrong page gets rewritten rather than argued +for, and that is a working sentence: rewrite the draft where it sits in the +queue, reseal it, refresh its review receipt, and run `wfctl work promotion <id>` +again. Their answer is never lost by correcting what it was about. + Approval and permission to start are different decisions and the record holds them separately. A maintainer who approves a framing and says the work is not to begin yet — including one approving only so the bundle stops cluttering their diff --git a/.workflow/rules/project-work-bundles.md b/.workflow/rules/project-work-bundles.md index de7c994..3a8cf30 100644 --- a/.workflow/rules/project-work-bundles.md +++ b/.workflow/rules/project-work-bundles.md @@ -33,6 +33,12 @@ Before claiming an issue, record a current review receipt for every required context file. Claim from the exact bound leaf before code work. Never infer a worktree from repository name, branch, sibling paths, or bundle location. +A bundle started from the centre without naming a leaf binds no source +repository, and delivery has nowhere to happen. Give it one from that +repository's own checkout with `wfctl work bind <id>`; `wfctl work rebind` moves +a binding the record already carries and is the wrong verb here. Neither is a +maintainer decision. + Wayfinder is deliberate and planning-only. It resolves precise question issues and fog into a reviewed specification before any delivery issue or product-code implementation begins. diff --git a/.workflow/rules/project-work-lifecycle.md b/.workflow/rules/project-work-lifecycle.md index 376aade..5849c8b 100644 --- a/.workflow/rules/project-work-lifecycle.md +++ b/.workflow/rules/project-work-lifecycle.md @@ -36,6 +36,8 @@ repository owns significant-work bundles and current project knowledge. bundle waits in the promotion queue rather than the archive, and the pages wait with it. `knowledge/` never holds a page nobody approved, and the corpus is never left invalid so that progress can look further along than it is. + A page they send back is rewritten where it sits, resealed, its receipt + refreshed, and recorded again with `wfctl work promotion <id>`. On a clean session or resume, run `wfctl work context --stage resume` without an ID. It may auto-select only one bound active record; multiple records require a diff --git a/.workflow/runtime/guard-stop.mjs b/.workflow/runtime/guard-stop.mjs index e53ff21..64f107c 100644 --- a/.workflow/runtime/guard-stop.mjs +++ b/.workflow/runtime/guard-stop.mjs @@ -281,23 +281,35 @@ function reason(message, awaiting) { "waiting on them, and the list above is the evidence. When you can act alone,", "act: take the next action, whether you named it or not.", "", - "When you are waiting on them, put it where the repository can see it. Record", - "it as a blocker on the owning checkpoint:", + "Two answers end this check, and they are different answers.", "", - " wfctl work checkpoint <id> --actor ... --blockers \"<what you need from them>\"", + "The maintainer is what the work is missing. Record it as a blocker, which", + "puts the work in their queue:", "", - "A checkpoint that names a blocker is held for the maintainer, and this check", - "goes quiet. Only the record does that: an agent blocked on a person wrote", - "what it needed in nine consecutive messages and was returned nine times,", - "because the repository kept reporting the work as its own.", + " wfctl work checkpoint <id> --actor <you> --status blocked \\", + " --blocker \"<what you need from them>\" \\", + " --state \"<where the work stands>\" --next \"<what happens once they answer>\"", + "", + "Nothing is missing except this session — the context is spent, or the next", + "unit does not fit in what is left. Record that instead, which tells the next", + "session and asks the maintainer nothing:", + "", + " wfctl work checkpoint <id> --actor <you> \\", + " --handoff \"<why this session stops here>\" \\", + " --state \"<where the work stands>\" --next \"<the next unit, named>\"", + "", + "Only the record ends this. An agent blocked on a person wrote what it needed", + "in nine consecutive messages and was returned nine times, because the", + "repository kept reporting the work as its own. A handoff is cleared by the", + "next checkpoint, so it says why you stopped here rather than granting a", + "standing licence to stop.", "", "Either way, run `wfctl resumable` before you end. It answers whether stopping", "now would lose anything; on a non-zero exit, refresh the checkpoint or commit,", "then end.", "", "This check returns while each turn moves the repository, and releases on the", - "first turn that does not. When a person is what you are missing, the blocker", - "above ends it. Answer with the next action, taken.", + "first turn that does not. Answer with the next action, taken.", ].join("\n"); } diff --git a/.workflow/state.json b/.workflow/state.json index e4009b4..f205cfc 100644 --- a/.workflow/state.json +++ b/.workflow/state.json @@ -7,16 +7,16 @@ "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" }, ".claude/rules/execution-continuity.md": { - "sha256": "5e77ad0c14faed7eb6661fb69f6ce4f118d3800da4ba6797c986b45714d20b3d" + "sha256": "164105e87a8f79fc5a004146f1cd22950b90d3bada7061b1a61dc6f8467396ee" }, ".claude/rules/maintainer-review.md": { - "sha256": "ee12e298ba4ee760e31c44f59447c9cd2d1bc2db075cdc6a3952cdc54cdd6939" + "sha256": "0c289acdb9624926261d45f68da7fe0215da066088eaafb3d477bb6b10c09eff" }, ".claude/rules/project-work-bundles.md": { - "sha256": "72c0f39dfd4f7d4d3eac35ddc8edfb6b5d6df96bea6fa97a14b3cc4cd835ced1" + "sha256": "f1598bedea4647318e5d28c4f65cc5a4369825a4324e98df3478d95a4d7327fe" }, ".claude/rules/project-work-lifecycle.md": { - "sha256": "bf40b682d2dbf063e4fbecff7cb7dff13fabedfce4817492f39087060200ae52" + "sha256": "3bcfe1d61b1e52c02afc5b08b381ed4c6c942eaac2f6c170e70817fd33322b8b" }, ".claude/rules/workflow-routing.md": { "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" @@ -28,16 +28,16 @@ "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" }, ".workflow/rules/execution-continuity.md": { - "sha256": "5e77ad0c14faed7eb6661fb69f6ce4f118d3800da4ba6797c986b45714d20b3d" + "sha256": "164105e87a8f79fc5a004146f1cd22950b90d3bada7061b1a61dc6f8467396ee" }, ".workflow/rules/maintainer-review.md": { - "sha256": "ee12e298ba4ee760e31c44f59447c9cd2d1bc2db075cdc6a3952cdc54cdd6939" + "sha256": "0c289acdb9624926261d45f68da7fe0215da066088eaafb3d477bb6b10c09eff" }, ".workflow/rules/project-work-bundles.md": { - "sha256": "72c0f39dfd4f7d4d3eac35ddc8edfb6b5d6df96bea6fa97a14b3cc4cd835ced1" + "sha256": "f1598bedea4647318e5d28c4f65cc5a4369825a4324e98df3478d95a4d7327fe" }, ".workflow/rules/project-work-lifecycle.md": { - "sha256": "bf40b682d2dbf063e4fbecff7cb7dff13fabedfce4817492f39087060200ae52" + "sha256": "3bcfe1d61b1e52c02afc5b08b381ed4c6c942eaac2f6c170e70817fd33322b8b" }, ".workflow/rules/workflow-routing.md": { "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" @@ -46,7 +46,7 @@ "sha256": "9198433e2a84e5cba17f2779aa9422f45f5e68d9bbe66d3084df3d9586cc016d" }, ".workflow/runtime/guard-stop.mjs": { - "sha256": "75f4c57a3356706423d3018783b5880c11588006721289689e14dbf356d846e6" + "sha256": "57f0059be7042f62f8a04b2f42ec72578be16580829567d986ba38eaa4a4ca5e" }, ".workflow/runtime/idle-guard.sh": { "sha256": "c2cb5ae64478631b10f86ce5dcd2f49241e9da8ba84fad6b1c3ffcc49f632a6b" diff --git a/AGENTS.md b/AGENTS.md index c63f21e..8a6c24f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,13 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und like a question nobody has answered. On a bundle, `--record <id>` writes the result into the framing, which the framing gate requires. - Ask one material question at a time, include a recommendation, and update - the durable record before continuing. + the durable record before continuing. Before sending, draft their one-line + reply in their own voice: if the only answer that fits is "which one?", you + wrote a status line and the question is still missing. Saying that something + waits on them is not asking. A count fails this test and so does a category — + "the boundaries", "your word on the shape" — while an alternative with its + consequence passes. It is a test rather than a list of phrases, because the + failure is identical in every language. - Write to a maintainer who was not watching. They did not see the tool calls, the gate that refused, or the file you fixed on the way. One message carries three things and stops: what is true now, what you need from them, what @@ -87,13 +93,28 @@ This block is managed by `wfctl`. Read `.workflow/config.json` and all files und that closes with "the work continues" or "the rest can wait" parks just as completely as one that names a next step and abandons it. If you are not waiting on them, take the next action you can take alone, in the same turn. - When you are waiting, say in one line what you need. A written report is - progress and never the finish line; completion is the terminal status of the - required records. This holds while executing accepted work and not while - shaping or specifying, where asking is the work. When several materially - different choices remain, present their human meaning, evidence, and - recommendation; after the maintainer chooses, execute the corresponding - commands yourself. + A written report is progress and never the finish line; completion is the + terminal status of the required records. This holds while executing accepted + work and not while shaping or specifying, where asking is the work. When + several materially different choices remain, present their human meaning, + evidence, and recommendation; after the maintainer chooses, execute the + corresponding commands yourself. +- Finishing a unit is not finishing. Completing an issue releases its claim, so + the bundle is left holding ready issues nobody has claimed — the shape every + long run passes through between units, and the moment a turn is most likely to + end on "next I will do X" and then not. The next unit is available work, and + available work is yours. +- Say why you are stopping, in the record, in the same turn. Prose is not state: + an explanation that lives only in a message goes with the session. Two answers + end a turn and they are different answers. The maintainer is what the work is + missing — record a blocker: `wfctl work checkpoint <id> --status blocked + --blocker "<what you need from them>"`, which moves it to their queue. Or + nothing is missing except this session, because the context is spent or the + next unit will not fit in what is left — record `--handoff "<why this session + stops here>"`, which tells the next session and asks them nothing. A handoff is + cleared by the next checkpoint, so it explains one stop rather than every stop + after it. Using a blocker for the second costs them a turn on a question that + was never theirs. - Before ending a turn, run `wfctl resumable`. It answers, from the repository rather than from your memory of it, whether stopping now would lose anything: a checkpoint describing a record that has since changed, an open record that diff --git a/PROJECT_WORKFLOW.md b/PROJECT_WORKFLOW.md index d246ee2..7e33236 100644 --- a/PROJECT_WORKFLOW.md +++ b/PROJECT_WORKFLOW.md @@ -75,7 +75,7 @@ skill by name. Raw text can tell the agent what to investigate. It cannot support a claim, even when several raw files agree. A trusted derivative must cite the -maintainer decision, pinned code, runtime receipt, reviewed archived change, or +maintainer decision, pinned code, runtime receipt, closed change, or primary external source that independently established the claim. ## Multiple inputs, one promotion gate @@ -341,13 +341,15 @@ At the start of significant work, the agent first creates a central bundle so the discussion cannot disappear after compaction. It then performs Graphify code analysis and QMD-assisted current-knowledge alignment before presenting the framing packet. QMD runs from the configured knowledge root and searches -only its `knowledge` collection by default. At the end, review the verification -and knowledge delta before accepting completion. +only its `knowledge` collection by default. At the end, you are shown the pages +the work would write into project knowledge, in full, and nothing enters until +you say so. Describe the desired change in ordinary language. The agent owns creation, -status checks, verification, and archival of the work record; you never need -its ID or commands. It asks you only for ambiguous routing, framing, product -authority, commit authorization, and completion decisions. +status checks, verification, closure, and archival of the work record; you never +need its ID or commands. It asks you only for ambiguous routing, framing, +product authority, commit authorization, and what the project now says about +itself. You may also ask read-only product questions here: diff --git a/skills-lock.json b/skills-lock.json index f2a40dd..2112cff 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -14,17 +14,17 @@ "curate-engineering-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "c7e0c90d212a52c82b8d974d7553a8a73517146d4ea7454749c5274758c05e9e" + "computedHash": "e4d658327bf87851941d0eb0130c7d15e72562e3ff9c6d51ef7ceb10c9383984" }, "curate-product-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "ce5c45e2449918252b18893c948ea6ed89267e7b3bd62dca2a86dfb99742e025" + "computedHash": "4e9a863ce20536e073744042e22159cc8a38c667f4d572729ed35db43b6d6530" }, "curate-project-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "26574b9003576326d9787a98a55db2c92409ddc516bd6327df0eab5a63bba08b" + "computedHash": "f79eb1b6dc2192f8385bd5f6a0f1d5515603465112ea3c34c22dee65e813c387" }, "explore-project-knowledge": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", @@ -34,12 +34,12 @@ "implement-work-item": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "ad5d30eb8c656ad1162116f1cf7318211c973ff694159081a121374432b782d2" + "computedHash": "a21a8c78d3371e81db35cbe1f0d52475de1dadb56a772ee9ca76913cd56cd403" }, "manage-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "1f33177bfcb13423c56bc9b7a6a4b1dac8d28b653a776847d785183bca10e93d" + "computedHash": "f51cef85094f0707199c5f62b2474e4333d67c5588bd6bc4d2c289070112e65c" }, "qmd": { "source": "/Users/lukachisama/.bun/install/global/node_modules/@tobilu/qmd/skills/qmd", @@ -69,12 +69,12 @@ "verify-knowledge-quality": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "3ba1e87f02ccfdb7ef5fc3be0438d37c3e4063780bd85fe86537747987740431" + "computedHash": "d825cc63dfc8eac2a1116567d1a3441dfdf5559fe10354bb7967eecc3e6143d9" }, "verify-project-work": { "source": "/Users/lukachisama/Documents/agent-skills/workflow", "sourceType": "local", - "computedHash": "335b19c9782d95f72ef5c6c07f66a4a8c814f9242c79f143cb0660f3685795a3" + "computedHash": "0b0bba317ec806d47ff4630b6e0afc62ab910c993894916a0bfd1c8fd0f2a35a" } } } From 6417901955ed23ef77e6d6e86514cbeaf2da954d Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 16:54:21 +0300 Subject: [PATCH 103/124] test(tx-manifest): the corpus proves both generations and both reference spellings are read --- packages/tx-manifest/src/generations.test.ts | 165 +++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 packages/tx-manifest/src/generations.test.ts diff --git a/packages/tx-manifest/src/generations.test.ts b/packages/tx-manifest/src/generations.test.ts new file mode 100644 index 0000000..30a053d --- /dev/null +++ b/packages/tx-manifest/src/generations.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; + +import currentDex from "./__fixtures__/current/dex.manifest.json"; +import currentLastWill from "./__fixtures__/current/last_will.manifest.json"; +import currentLendingV2 from "./__fixtures__/current/lending_v2.manifest.json"; +import currentLendingV3 from "./__fixtures__/current/lending_v3.manifest.json"; +import currentZeroconf from "./__fixtures__/current/zeroconf.manifest.json"; +import dex from "./__fixtures__/dex.manifest.json"; +import lastWill from "./__fixtures__/last_will.manifest.json"; +import lending from "./__fixtures__/lending.manifest.json"; +import lendingV2 from "./__fixtures__/lending_v2.manifest.json"; +import lendingV3 from "./__fixtures__/lending_v3.manifest.json"; +import p2pkGrouped from "./__fixtures__/p2pk-grouped.manifest.json"; +import p2pk from "./__fixtures__/p2pk.manifest.json"; +import zeroconf from "./__fixtures__/zeroconf.manifest.json"; +import { normaliseManifest } from "./document/normalise"; +import { parseReference } from "./document/references"; + +// Both criteria this file answers are about the published corpus rather than about +// documents written to suit the reader, so every assertion below counts something in +// these twelve files. Three generations coexist in them: the older container spelling, +// the current one, and one document written in the oldest spelling of all. + +const OLDER = { + dex, + last_will: lastWill, + lending, + lending_v2: lendingV2, + lending_v3: lendingV3, + p2pk, + "p2pk-grouped": p2pkGrouped, + zeroconf, +} as unknown as Record<string, Record<string, unknown>>; + +const CURRENT = { + dex: currentDex, + last_will: currentLastWill, + lending_v2: currentLendingV2, + lending_v3: currentLendingV3, + zeroconf: currentZeroconf, +} as unknown as Record<string, Record<string, unknown>>; + +const ALL = { ...OLDER, ...CURRENT }; + +/** Every string anywhere in a document, with the key it sat under. */ +function strings(node: unknown, at = ""): { at: string; text: string }[] { + if (typeof node === "string") { + return [{ at, text: node }]; + } + + if (Array.isArray(node)) { + return node.flatMap((item, index) => strings(item, `${at}[${index}]`)); + } + + if (node && typeof node === "object") { + return Object.entries(node).flatMap(([key, value]) => + strings(value, at === "" ? key : `${at}.${key}`), + ); + } + + return []; +} + +/** The two reference spellings, counted where a reference can actually appear. */ +function referenceSpellings(document: Record<string, unknown>) { + let deprecated = 0; + let current = 0; + + for (const { at, text } of strings(document)) { + // The wiring map keeps the older name as a key and is not a reference. Renaming it + // would change which parameters a covenant compiles with, and therefore its address. + if (at.includes("compile_params.") && !text.startsWith("compile_params.")) { + continue; + } + + const reference = parseReference(text); + + if (reference?.form !== "instance") { + continue; + } + + if (reference.deprecated) { + deprecated += 1; + } else { + current += 1; + } + } + + return { current, deprecated }; +} + +describe("the version field cannot decide the generation", () => { + test("every document in every generation declares the same format version", () => { + const declared = new Set( + Object.values(ALL).map((document) => document.manifest_version ?? document.compose_version), + ); + + expect([...declared]).toEqual(["0.1.0"]); + }); + + test("so the container spelling is what actually differs, and both are read", () => { + for (const [name, document] of Object.entries(OLDER)) { + expect({ [name]: "classes" in document || "actions" in document }).toEqual({ + [name]: true, + }); + } + + for (const [name, document] of Object.entries(CURRENT)) { + // zeroconf declares no actions in either generation, so it carries no container at + // all. It is the smallest real document there is and it has to keep reading. + const expected = name !== "zeroconf"; + + expect({ [name]: "contract_templates" in document }).toEqual({ [name]: expected }); + } + }); + + test("and a document is normalised without its version being consulted", () => { + for (const [name, document] of Object.entries(ALL)) { + const withoutVersion = { ...document }; + + delete withoutVersion.manifest_version; + delete withoutVersion.compose_version; + + expect({ [name]: normaliseManifest(withoutVersion).manifest.actions }).toEqual({ + [name]: normaliseManifest(document).manifest.actions, + }); + } + }); +}); + +describe("both reference spellings are live in the published corpus", () => { + test("two protocols still carry the older spelling, in both generations", () => { + const older = Object.entries(ALL) + .filter(([, document]) => referenceSpellings(document).deprecated > 0) + .map(([name]) => name) + .toSorted(); + + expect(older).toEqual(["last_will", "lending"]); + }); + + test("the rest carry the current one, so neither spelling can be dropped", () => { + const current = Object.entries(ALL) + .filter(([, document]) => referenceSpellings(document).current > 0) + .map(([name]) => name) + .toSorted(); + + expect(current).toEqual(["dex", "lending_v2", "lending_v3"]); + }); + + test("and no document mixes them, which is what makes the split a generation", () => { + for (const [name, document] of Object.entries(ALL)) { + const { current, deprecated } = referenceSpellings(document); + + expect({ [name]: current > 0 && deprecated > 0 }).toEqual({ [name]: false }); + } + }); + + test("the reader resolves both to the same lookup", () => { + const deprecated = parseReference("compile_params.OWNER"); + const current = parseReference("instance.OWNER"); + + expect(deprecated).toMatchObject({ deprecated: true, form: "instance", name: "OWNER" }); + expect(current).toMatchObject({ form: "instance", name: "OWNER" }); + }); +}); From 6b2030a568880055d110b54708652134e3e4d1cd Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 17:09:21 +0300 Subject: [PATCH 104/124] feat(tx-manifest): deploy a contract instance, in whichever generation names the constructor --- packages/tx-manifest/src/corpus.test.ts | 8 +- .../src/covenants/instance.test.ts | Bin 0 -> 7184 bytes .../tx-manifest/src/covenants/instance.ts | 304 ++++++++++++++++++ packages/tx-manifest/src/currency.test.ts | 5 +- .../tx-manifest/src/document/registry.test.ts | 2 +- packages/tx-manifest/src/document/registry.ts | 9 +- packages/tx-manifest/src/review/index.ts | 25 +- 7 files changed, 340 insertions(+), 13 deletions(-) create mode 100644 packages/tx-manifest/src/covenants/instance.test.ts create mode 100644 packages/tx-manifest/src/covenants/instance.ts diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index f3abe19..dc0801f 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -180,11 +180,11 @@ describe("what this wallet can do with each published protocol", () => { // Recorded as the measurement rather than as an expectation: these five refuse today, and // the first construct each refuses on is what a slice implementing it would remove. test.each([ - ["dex", "is_constructor"], - ["last_will", "is_constructor"], + ["dex", "default"], + ["last_will", "default"], ["lending", "confidential"], ["lending_v2", "confidential"], - ["lending_v3", "is_constructor"], + ["lending_v3", "default"], ])("%s refuses, on %s", (name, construct) => { expect(refusalFor(name)).toContain(construct); }); @@ -233,9 +233,7 @@ describe("what the corpus uses and this wallet does not read", () => { expect([...unread].toSorted()).toEqual([ "confidential", - "create_instance", "default", - "is_constructor", "issuance", "on_pre_broadcast", "on_resolved", diff --git a/packages/tx-manifest/src/covenants/instance.test.ts b/packages/tx-manifest/src/covenants/instance.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..86fd8a15cd94cf7aad6d1bbf436eb2cf8eb5034f GIT binary patch literal 7184 zcmd^D-E!N;749wc6dR5wg*+^z+D<N*${kszBh{9fv1GTsqL;uzL<C~l#X=NE;Y=T* zPuM5vch2sDpd`vp$Ky$-xo|}6*|X>OJ3qy2u8kEpB2#s0iizqAbv;+9?F*}FD^?;m zdM0|4rW(+P-VZxFg}akB#;D4^RM(y+N%qF$ytuZFQT2Gd=Lz;Qb)C#)Rphvz{9Nk_ zOg4GEk+uD6QI?-zRDx{00NplrMDPHgPv2KnR#X=so^BDObm!x%r`sgB5qVK%hp8=e z)fbgEGg%h3I`!(e_Jpl6oi;NVaId9W+vP?ajXn(D9=<&vA0M6_kI#m~m+QNGsh-Un ztFrEe)F=rLom91zRSG}C3A(&eNBlht-#c6iPf9-B+Y`epWtKwLwK62Vn5a@OglVc; z$O}otLW+4afj(1}iMi2Mr@9nI+Nm<aPGtpxpp{sd!dg`ctBHBt6c!{3Qprr2M4aRA zRD-E$QmaiZ3Xt+QGjH&sQT$U^<r06uDfm}fk>ZhBF2!Q1_*F^FGc_;ul59z&wOGiq zR2EOA+GOBdGpi0&Ve7U0A@b`4W_bKq915~v_6G<=b}mX~22gA&>#36gj1UL4fQ?9E zX)f50bsvIQWoAWH)V4?=#4-_&_jdB8;&@;dqddM@U-=8Np)YFPm{f_?0D0JQqoV;t zG0^=7PZEBM)o%Oe%}&I26j^aW8G9fqwGc0wDmgOCxz)#zTba05Pvw*EzVGeA*^whn znt6thi9bHNaT2V4_vmKh{nbY%Hwr0T`3DkrIqpr>HE~<*tQG7y6nchG7>K62tn`8s zLe5l>d{?xh--v(@2u~<*J8fE42v6~j-TEhInSS8?9HMwbNN4SXPAbNMzY~GADsmBj z<7iow$W9Fco~$=CMw|HKTP-{<k~<TRZs`APln81^-Y$-YE#W+IHhTZ=Xm|!qpkXiB zqnxs1+1P1P7qh%K5WT#D%0(69<yEiGR5N+0%y=%By0H(@5l+t^X19WqLs-W>%1dVw zP9c{ENHNMkYj8vvLdkN0z=fI!d{uCEU8qVSy@3Y3zJRO;&q*Vy196W+AGSi~uc?rp z#uSQDgVR=tNvYGz?&aG)+6)<AGD1D*i{7iI%3RP!t}w;7fG&2r?d~R4zfdvoL{TUN zYImAcI#Wqek2}w~3-7B;0kTyV)2$Up##%vL0<547u*z#jYUicA06f-3rbK~C3AA7& zU}ede3xX;(s?0uL`B1&Sg4V5>x(aWJlS(`Mg+BPxg-w9%4du4@r(<$}!d(xaRKTag z_I85{b6M8xLlTY!T*oq-zfdK?Z+;ing%6IdT+W+{t6NT2p+Z}{WPRzX!(15vuE@0^ z$2o74SB-8ired;W=<2fdf$Sd4kn8u?+xS!Jy8!P~VnxK0GxCfpLOl>3N`MeiL>1^r zU#Ut~cHoN%AJnOy16G+s6skIYulO{YOCx9C<<6s{(VLg!pWeS1zaIV_NR+0%#Gs-{ z8$W%r>uyBHqw@~QWO5J5$=l=MyOZ<LyAGk0+X=@n-i(f3pW($nK6^%xhyR>_{I3hd zkhR{bl%l87rqj{nXY`~oYtT`UnXpfP!|Fu{A+!qvvCm37o%Q8F5LD48Qhft?Bq|rh zDZ@3Qk&xQC{r9!&LnuUDZ<1~HU-cyb96|iWA{FgD@aPn^X_V*6^me~uq#mA*-lGG4 zK{2zxzwd)4Iy^fYy07;J=IP<<;U|Og^zd)vSHmGVf462ldi4r7-K#ix(s|P#@yjPK zad>aqoS&QyNAJ%`-p`KlL|<-}E<^;kCO!w~+@-E-HxMh_d_-t`&lg&_=<-DfzY!X) zMrlCVW^bPA8o=Qo)Sy-LV*~}*l`I<+Hq3fp56(nXE5uT$Z8iD>lsRzc;syY{LY+rb z59}~HYf}CNR9;kQATXUIXrx^jqiLf4z0!^~$_Z*}7-2jO8fJ`t6<&1W<FUM;R{Fvz znBeY4eaVu51BtVMH{ugYCbkVUa9VK&ar^E|MnKth4lU5{u+Eo?#tj-%{?Sl3T{rW2 zS*VO=IeW`AYeTXGG&UFv7CbPM*{E6)4e`Xpq00OrEgXy`E_%;?dcBLD#k!v6?kvbk zZnMV&oI*^sw%effMd4R6rxD{yy;0SLoyOlky9;KmxffGiBdpxCvp_3P777-2{Zigg z2l}PU?Pr6SFPUGc(*9rI*PR+IsJ3wrhcT6=O@^}&dE3zJRxo2tfOd>{AE70pNeOe* z-3jS#ux&t2z_m(Gbhf-V)Vc^+s@!5?k=1<@i&x+`(dYqT-Ij-&Jkd6IEAcCOsat;H z4&+w1JpopWdalY6K+GLCwIXi!<bj(OHMG;|HXKx$fP3p*5m=-}j+F>6maxB}2~mj! z)-&tr3CkMV{P3woA>%qxi+}&~U!uZ*W|gXTxHJ_r)Ecf!3xx%ju=Jr%Hlcxx5Vyvd zj`D?`sqOS@sz+F65itu)rG<oWH34N0Qj;R6KX@D?53P>cg5j1@0Cl4XqFj+e$8ULJ zkaUyAhtRPRu*nGqc@l?Nw-X1KOolmBgY~2ttjzNJAqo4WYeBDdjqB$Q2i%Cp7zyUS zxlP)7#=3)gZVGmRJPsj5D;4gmBgb-Zd%?10-}0R5EIN@W+D+U|&UK&1?#sHG{W-DA zx$-r-XXU(9GN-wIv+?zVbZZQZq;3TFo7e8H;lD79=knnxTG}JH%!a1+Ygh(laNTp! z!03jWgqxH)b`~RBmRKUPS7ScnwF_Af!RQ*AgM$On(>xOO#JAu2uixNn)u4;Qmmcq# zXp_S?Xv%O<9AMTbaRQ5=Y&*aF$QCi@Smeh0<eIkQ3e-GA#~rbld*UHMuw22n;+J3C z<Lvp(voPsX1cfQ+PAqtUY4HY4fvlEp7oo5r=rjzx)uV)Vix=eH19Onyg&hFN+V%4~ zY3^A)`SfzTEcmUvQo1jhoZuMvwq%aLdM@z9juhK(v^=~kjG`n-9D8zEL|dycx3?!8 zt9%^YdOeU57v4oVsy4B2L^$vn+sA(4ba;Mz^7cpE4bYD9MUi8X9snMSBz#HsuH8}+ z8(KHQZcOi-`#!=sj2_sC1f9R)B>BI*^SD)~5f?NbI@f1gr^Arw&KcP7-07!UABW7N G;{P8wQRuh; literal 0 HcmV?d00001 diff --git a/packages/tx-manifest/src/covenants/instance.ts b/packages/tx-manifest/src/covenants/instance.ts new file mode 100644 index 0000000..ccbe0be --- /dev/null +++ b/packages/tx-manifest/src/covenants/instance.ts @@ -0,0 +1,304 @@ +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; +import { resolveCompileParams } from "./compileParams"; +import { COVENANT_HASH_SEED, type HashCovenant, ITERATION_BOUND } from "./computed"; + +/** + * The deployment an action creates, once its field values are worked out. + * + * `rounds` is how many passes the covenant hashes took to settle, and is zero when the + * action's fields hold no computed value at all. + */ +export type CreatedInstance = { fields: Record<string, string>; rounds: number }; + +export type CreateInstanceResult = + | { instance: CreatedInstance; ok: true } + | { ok: false; reason: string }; + +/** + * Whether this action deploys a new contract instance. + * + * Two generations spell it differently and one of them spells it twice. The older writes a + * boolean flag beside a block; the newer dropped the flag on the ground that an action + * carrying the block *is* the constructor, and there is nothing a flag could add that the + * block does not already say. Six of the corpus's eleven constructors carry both spellings + * and five carry only the block, so reading the block alone reads every one of them — + * and a flag with no block is a document declaring a constructor that constructs nothing, + * which is a fault rather than a generation. + */ +export function createsInstance(action: NormalisedAction): boolean { + return asRecord(action.node.create_instance) !== undefined; +} + +/** + * What a field's value is computed by, whichever word this document uses for it. + * + * Three spellings name the same thing. The published specification says `lang`; the corpus + * says `compute` nineteen times and `type` twelve, inside the same seven protocols. None of + * them is a version marker — both appear in the current generation — so a runtime that reads + * one reads two thirds of the corpus and refuses the rest for a reason that is not about the + * protocol. + */ +function computeKind(node: Record<string, unknown>): string | undefined { + for (const key of ["compute", "type", "lang"]) { + const value = node[key]; + + if (typeof value === "string") { + return value; + } + } + + return undefined; +} + +/** + * Works out the field values of the instance an action deploys. + * + * A field is either a value the request or an earlier deployment already holds — reached by + * reference — or a covenant's script hash, which has to be compiled to be known. The second + * kind may name other fields of the same new instance, including in a cycle, so it is + * resolved the way the reference implementation resolves computed parameters: every + * unknown starts at a seed, all of them are recomputed together, and the round that + * reproduces its own input is the answer. + * + * **A literal stays a literal.** Some fields hold `"0"` or `"2"` rather than a reference, + * and a manifest saying a field is two means two. Resolution is tried first and a failure + * to resolve is not an error for a string that names nothing. + */ +export function resolveCreatedInstance( + action: NormalisedAction, + input: { + contractSources: Record<string, string>; + hashCovenant: HashCovenant; + notes?: NormalisationNote[]; + scope: ReferenceScope; + }, +): CreateInstanceResult { + const block = asRecord(action.node.create_instance); + + if (!block) { + return { ok: false, reason: "This action does not create an instance." }; + } + + const declared = asRecord(block.fields); + + if (!declared) { + return { ok: false, reason: "The action creates an instance and declares no fields for it." }; + } + + const direct: Record<string, string> = {}; + const computed: ComputedField[] = []; + + for (const [name, value] of Object.entries(declared)) { + if (typeof value === "string") { + const resolved = resolveFieldReference(name, value, input.scope, input.notes); + + if (!resolved.ok) { + return resolved; + } + + direct[name] = resolved.value; + continue; + } + + const node = asRecord(value); + + if (!node) { + return { ok: false, reason: `Field ${name} is neither a reference nor a computed value.` }; + } + + const kind = computeKind(node); + + if (kind !== "tapleaf") { + return { + ok: false, + reason: + `Field ${name} is computed by "${String(kind)}", which this runtime does not ` + + "implement. Honouring it means executing a contract while building the " + + "transaction, not merely compiling one.", + }; + } + + if (asArray(node.extra_leaves).length > 0) { + return { + ok: false, + reason: `Field ${name} carries extra_leaves, which this runtime does not encode yet.`, + }; + } + + const simf = node.simf; + + if (typeof simf !== "string") { + return { ok: false, reason: `Field ${name} names no contract to compute from.` }; + } + + const source = input.contractSources[simf]; + + if (source === undefined) { + return { ok: false, reason: `The source of ${simf} was not supplied.` }; + } + + computed.push({ name, node, source }); + } + + if (computed.length === 0) { + return { instance: { fields: direct, rounds: 0 }, ok: true }; + } + + const declaredTypes = fieldTypes(declared); + let values: Record<string, string> = Object.fromEntries( + computed.map(({ name }) => [name, COVENANT_HASH_SEED]), + ); + + for (let round = 1; round <= ITERATION_BOUND; round += 1) { + const next: Record<string, string> = {}; + + for (const { name, node, source } of computed) { + // A tapleaf's own wiring names fields of the instance being created, so the scope a + // covenant compiles against is this instance rather than the request's parameters. + const scope: ReferenceScope = { + ...input.scope, + params: { ...input.scope.params, ...direct, ...values }, + }; + const wiring = tapleafWiring(node); + + if (!wiring.ok) { + return { ok: false, reason: `Computing ${name}: ${wiring.reason}` }; + } + + const resolved = resolveCompileParams(wiring.wiring, declaredTypes, scope, input.notes); + + if (!resolved.ok) { + return { ok: false, reason: `Computing ${name}: ${resolved.reason}` }; + } + + next[name] = input.hashCovenant({ + argumentsJson: JSON.stringify(resolved.arguments), + source, + }); + } + + if (computed.every(({ name }) => next[name] === values[name])) { + return { instance: { fields: { ...direct, ...next }, rounds: round }, ok: true }; + } + + values = next; + } + + return { + ok: false, + reason: + `The covenant hashes this deployment's fields compute never settle: ${computed + .map(({ name }) => name) + .join(", ")} still change after ${ITERATION_BOUND} rounds. ` + + "A deployment recorded from values that never agreed with themselves would locate " + + "funds at an address nobody checked.", + }; +} + +type ComputedField = { name: string; node: Record<string, unknown>; source: string }; + +/** + * The wiring a tapleaf's parameters describe, in the shape the compile-parameter resolver reads. + * + * The two positions spell the same thing differently, and this is where they meet. A + * covenant's own wiring map holds references — `{"PUB_KEY": "MAKER_PUB_KEY"}`. A tapleaf + * inside a deployment's fields holds objects — `{"PUB_KEY": {"type": "pubkey", "value": + * "MAKER_PUB_KEY"}}` — because the declaration carries the type at the point of use rather + * than from a parameter declared elsewhere. The reference is the `value`, and the `type` + * beside it is what the encoder needs. + */ +function tapleafWiring( + node: Record<string, unknown>, +): { ok: false; reason: string } | { ok: true; wiring: Record<string, unknown> } { + const declared = asRecord(node.params); + + if (!declared) { + return { ok: true, wiring: {} }; + } + + const wiring: Record<string, unknown> = {}; + + for (const [name, spec] of Object.entries(declared)) { + if (typeof spec === "string") { + wiring[name] = spec; + continue; + } + + const value = asRecord(spec)?.value; + + if (typeof value !== "string") { + return { ok: false, reason: `Parameter ${name} names no value to compile with.` }; + } + + wiring[name] = value; + } + + return { ok: true, wiring }; +} + +/** + * Reads one field written as a string. + * + * The corpus writes four reference spellings here and two literals. A string that resolves + * is its value; a string that names nothing is itself, because a field holding `"2"` is a + * field holding two rather than a broken reference. + */ +function resolveFieldReference( + name: string, + text: string, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: string } { + const found = resolveReference(text, "compileParam", scope, notes); + + if (!found.ok) { + // Only a text that could not name anything falls through to being a literal. One that + // named something absent is a document asking for a value nobody supplied, and saying + // "the field is the string $params.X" would hide that. + return text.startsWith("$") || text.includes(".") + ? { ok: false, reason: `Field ${name}: ${found.reason}` } + : { ok: true, value: text }; + } + + if (typeof found.value !== "string") { + return { + ok: false, + reason: `Field ${name} resolves to a value this runtime cannot record as a field yet.`, + }; + } + + return { ok: true, value: found.value }; +} + +/** + * The declared types of the fields a tapleaf's wiring can name. + * + * A computed field is a covenant's script hash and is thirty-two bytes by construction, so + * it needs no declaration. Every other field takes the type its own tapleaf parameters + * declare, which is where the corpus states them. + */ +function fieldTypes(declared: Record<string, unknown>): Record<string, string> { + const types: Record<string, string> = {}; + + for (const [name, value] of Object.entries(declared)) { + const node = asRecord(value); + + if (node && computeKind(node) === "tapleaf") { + types[name] = "bytes32"; + + for (const [param, spec] of Object.entries(asRecord(node.params) ?? {})) { + const type = asRecord(spec)?.type; + const target = asRecord(spec)?.value; + + if (typeof type === "string" && typeof target === "string") { + types[target] = type; + types[param] ??= type; + } + } + } + } + + return types; +} diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts index ae8e509..2db06dd 100644 --- a/packages/tx-manifest/src/currency.test.ts +++ b/packages/tx-manifest/src/currency.test.ts @@ -97,17 +97,15 @@ describe("the protocols as their authors publish them now", () => { expect(blocked).toEqual({ dex: [ "confidential", - "create_instance", "default", "required_index", "simplicity_type", "value", ], - last_will: ["create_instance", "default", "required_index", "simplicity_type", "value"], + last_will: ["default", "required_index", "simplicity_type", "value"], lending_v2: [ "compute", "confidential", - "create_instance", "default", "issuance", "on_pre_broadcast", @@ -119,7 +117,6 @@ describe("the protocols as their authors publish them now", () => { lending_v3: [ "compute", "confidential", - "create_instance", "default", "issuance", "on_resolved", diff --git a/packages/tx-manifest/src/document/registry.test.ts b/packages/tx-manifest/src/document/registry.test.ts index ddde785..2acf135 100644 --- a/packages/tx-manifest/src/document/registry.test.ts +++ b/packages/tx-manifest/src/document/registry.test.ts @@ -69,7 +69,7 @@ describe("inspectConstructs", () => { // The refusal itself is ISSUE-021. What this slice owes it is the distinction. describe("load-bearing constructs are separated out", () => { test("a construct the format declares and this runtime does not implement", () => { - const finding = at(inspect({ actions: { Pay: { create_instance: {} } } }), "create_instance"); + const finding = at(inspect({ actions: { Pay: { on_pre_broadcast: {} } } }), "on_pre_broadcast"); expect(finding).toMatchObject({ declared: true, loadBearing: true }); }); diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index 535ab90..ef7ab90 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -163,7 +163,7 @@ const SITES = { action: { constructs: { args: UNIMPLEMENTED, - create_instance: UNIMPLEMENTED, + create_instance: READ, description: SHOWN, inputs: READ, // A sentence saying what this action does, written for whoever approves it, beside the @@ -172,7 +172,12 @@ const SITES = { // specification describes, and a confident sentence about the wrong amounts changes // what a person agrees to. intent: UNREAD, - is_constructor: UNIMPLEMENTED, + // The older generation's flag beside the block. The newer one dropped it, on the + // ground that an action carrying the block is the constructor and a flag adds + // nothing; six of the corpus's eleven constructors carry both and five carry only + // the block. So it is read for nothing, which is different from being ignored: + // what it asserts is asserted better by the block beside it. + is_constructor: UNREAD, on_input_resolved: UNIMPLEMENTED, on_post_broadcast: UNIMPLEMENTED, on_pre_broadcast: UNIMPLEMENTED, diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index 502e6a7..f94e8ca 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -7,6 +7,7 @@ import { deriveCovenantAddress, } from "../covenants/covenant"; import { type CompileScriptPubKey, covenantHashFrom } from "../covenants/covenantHash"; +import { createsInstance, resolveCreatedInstance } from "../covenants/instance"; import { asArray, asRecord } from "../document/json"; import { findAction, @@ -229,9 +230,31 @@ export async function reviewManifestAction( return { reason: computed.reason, refused: true, reject: "document-fault" }; } + // A constructor has no deployment to read and creates one instead, so its field values are + // worked out here rather than arriving with the request. They join the scope under the same + // name every other reference reads, because the covenant this action locks funds into is + // compiled with the deployment it is creating. + const created = createsInstance(action) + ? resolveCreatedInstance(action, { + contractSources: request.contractSources, + hashCovenant: covenantHashFrom(input.scriptPubKeyOf), + notes, + scope: { + instance: deployment.instance.fields, + params: { ...request.params, ...computed.values }, + }, + }) + : undefined; + + if (created && !created.ok) { + return { reason: created.reason, refused: true, reject: "document-fault" }; + } + const scope: ReferenceScope = { inputs, - instance: deployment.instance.fields, + instance: created + ? { ...deployment.instance.fields, ...created.instance.fields } + : deployment.instance.fields, params: { ...request.params, ...computed.values }, }; From 5289c1948f4fdac11a02babd9a7692b6433ef038 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 17:14:26 +0300 Subject: [PATCH 105/124] feat(tx-manifest): run the hooks that compute values after inputs resolve --- packages/tx-manifest/src/corpus.test.ts | 2 - packages/tx-manifest/src/currency.test.ts | 11 +- .../tx-manifest/src/document/registry.test.ts | 2 +- packages/tx-manifest/src/document/registry.ts | 4 +- .../tx-manifest/src/evaluation/hooks.test.ts | 146 ++++++++++++++++++ packages/tx-manifest/src/evaluation/hooks.ts | 142 +++++++++++++++++ packages/tx-manifest/src/review/index.ts | 75 ++++++++- 7 files changed, 365 insertions(+), 17 deletions(-) create mode 100644 packages/tx-manifest/src/evaluation/hooks.test.ts create mode 100644 packages/tx-manifest/src/evaluation/hooks.ts diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index dc0801f..bf5d466 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -235,8 +235,6 @@ describe("what the corpus uses and this wallet does not read", () => { "confidential", "default", "issuance", - "on_pre_broadcast", - "on_resolved", "required_index", "simplicity_type", "source", diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts index 2db06dd..27de0af 100644 --- a/packages/tx-manifest/src/currency.test.ts +++ b/packages/tx-manifest/src/currency.test.ts @@ -95,21 +95,13 @@ describe("the protocols as their authors publish them now", () => { ); expect(blocked).toEqual({ - dex: [ - "confidential", - "default", - "required_index", - "simplicity_type", - "value", - ], + dex: ["confidential", "default", "required_index", "simplicity_type", "value"], last_will: ["default", "required_index", "simplicity_type", "value"], lending_v2: [ "compute", "confidential", "default", "issuance", - "on_pre_broadcast", - "on_resolved", "required_index", "simplicity_type", "value", @@ -119,7 +111,6 @@ describe("the protocols as their authors publish them now", () => { "confidential", "default", "issuance", - "on_resolved", "required_index", "simplicity_type", "value", diff --git a/packages/tx-manifest/src/document/registry.test.ts b/packages/tx-manifest/src/document/registry.test.ts index 2acf135..667da20 100644 --- a/packages/tx-manifest/src/document/registry.test.ts +++ b/packages/tx-manifest/src/document/registry.test.ts @@ -69,7 +69,7 @@ describe("inspectConstructs", () => { // The refusal itself is ISSUE-021. What this slice owes it is the distinction. describe("load-bearing constructs are separated out", () => { test("a construct the format declares and this runtime does not implement", () => { - const finding = at(inspect({ actions: { Pay: { on_pre_broadcast: {} } } }), "on_pre_broadcast"); + const finding = at(inspect({ actions: { Pay: { on_validate: {} } } }), "on_validate"); expect(finding).toMatchObject({ declared: true, loadBearing: true }); }); diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index ef7ab90..a0935f1 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -180,7 +180,7 @@ const SITES = { is_constructor: UNREAD, on_input_resolved: UNIMPLEMENTED, on_post_broadcast: UNIMPLEMENTED, - on_pre_broadcast: UNIMPLEMENTED, + on_pre_broadcast: READ, // A full SimplicityHL program, not a formula: honouring it means executing a // contract at build time. Out of scope for this change and named rather than absent. on_validate: UNIMPLEMENTED, @@ -200,7 +200,7 @@ const SITES = { from_address: READ, id: READ, issuance: UNIMPLEMENTED, - on_resolved: UNIMPLEMENTED, + on_resolved: READ, // The action tolerates this input's absence. The wallet includes what it is given // and never drops one, which is inside what the declaration permits. optional: SHOWN, diff --git a/packages/tx-manifest/src/evaluation/hooks.test.ts b/packages/tx-manifest/src/evaluation/hooks.test.ts new file mode 100644 index 0000000..8cf7bbf --- /dev/null +++ b/packages/tx-manifest/src/evaluation/hooks.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; + +import lendingV2 from "../__fixtures__/lending_v2.manifest.json"; +import { asArray, asRecord } from "../document/json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import { actionHook, inputHook, inputHookScope, runHook, withHookValues } from "./hooks"; + +// The hooks in the corpus are the only ones that can say whether this reads what protocols +// actually write. Three actions declare an action-level hook and eighteen inputs declare +// their own; the two shapes below are lifted from `lending_v2`, which carries both. + +function actionOf(document: unknown, name: string) { + const { manifest } = normaliseManifest(document as Record<string, unknown>); + const action = findAction(manifest, name); + + if (!action) { + throw new Error(`No action named ${name}`); + } + + return action; +} + +/** The one action in the corpus whose hook does arithmetic rather than copying a value. */ +function encodingAction() { + const { manifest } = normaliseManifest(lendingV2 as unknown as Record<string, unknown>); + + for (const container of ["classes", "contract_templates"] as const) { + for (const template of Object.values(asRecord(manifest.raw?.[container]) ?? {})) { + const actions = + asRecord(asRecord(template)?.methods) ?? asRecord(asRecord(template)?.actions); + + for (const name of Object.keys(actions ?? {})) { + const action = findAction(manifest, name); + + if (action && actionHook(action)) { + return action; + } + } + } + } + + throw new Error("The corpus carries no action-level hook"); +} + +describe("an action's own hook", () => { + test("is found where the corpus writes it", () => { + expect(actionHook(encodingAction())).toBeDefined(); + }); + + test("computes its values from the parameters the request supplied", () => { + const action = encodingAction(); + const set = actionHook(action); + + if (!set) { + throw new Error("no hook"); + } + + const scope: ReferenceScope = { + instance: {}, + params: { + AMOUNTS_DECIMALS: "2", + COLLATERAL_AMOUNT: "1000", + LOAN_EXPIRATION_TIME: "10", + PRINCIPAL_AMOUNT: "500", + PRINCIPAL_INTEREST_RATE: "5", + }, + }; + const ran = runHook(set, scope, []); + + expect(ran.ok).toBe(true); + + if (!ran.ok) { + return; + } + + // Every target this hook names is a field of the deployment, and every value it + // produced is a number rather than the expression it came from. + expect(Object.keys(ran.values.instance).length).toBeGreaterThan(0); + + for (const value of Object.values(ran.values.instance)) { + expect(value).toMatch(/^-?\d+$/); + } + }); + + test("a later line reads what an earlier one set", () => { + const scope: ReferenceScope = { instance: {}, params: { BASE: "10" } }; + const ran = runHook( + { "instance.FIRST": "params.BASE * 2", "instance.SECOND": "instance.FIRST + 1" }, + scope, + [], + ); + + expect(ran).toMatchObject({ ok: true, values: { instance: { FIRST: "20", SECOND: "21" } } }); + }); + + test("a target naming a namespace this runtime cannot set is refused", () => { + const ran = runHook({ "outputs.X": "1" }, { instance: {}, params: {} }, []); + + expect(ran).toMatchObject({ ok: false }); + + if (ran.ok) { + return; + } + + expect(ran.reason).toContain("outputs"); + }); + + test("the deprecated namespace writes where the current one does", () => { + const ran = runHook({ "compile_params.X": "2 + 2" }, { instance: {}, params: {} }, []); + + expect(ran).toMatchObject({ ok: true, values: { instance: { X: "4" } } }); + }); +}); + +describe("an input's own hook", () => { + test("is found where the corpus writes it, and reads the asset that input holds", () => { + const action = actionOf(lendingV2, "LockCollateral"); + const withHook = asArray(action.node.inputs) + .map((entry) => asRecord(entry)) + .find((entry) => entry && inputHook(entry)); + + // LockCollateral may carry none; the assertion that matters is that where one exists, + // the bare name it reads resolves to what the input turned out to hold. + const scope = inputHookScope( + { instance: {}, params: {} }, + { amount_sat: 1000n, asset: "aa".repeat(32) }, + ); + + expect(scope.params.asset).toBe("aa".repeat(32)); + expect(withHook === undefined || inputHook(withHook) !== undefined).toBe(true); + }); + + test("what it sets is visible to everything after it", () => { + const scope: ReferenceScope = { instance: {}, params: {} }; + const ran = runHook({ "instance.ISSUED": "asset" }, inputHookScope(scope, { asset: "42" }), []); + + expect(ran).toMatchObject({ ok: true, values: { instance: { ISSUED: "42" } } }); + + if (!ran.ok) { + return; + } + + expect(withHookValues(scope, ran.values).instance?.ISSUED).toBe("42"); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/hooks.ts b/packages/tx-manifest/src/evaluation/hooks.ts new file mode 100644 index 0000000..6fe716c --- /dev/null +++ b/packages/tx-manifest/src/evaluation/hooks.ts @@ -0,0 +1,142 @@ +import { asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import { evaluateExpression } from "./evaluate"; + +/** + * Where a hook writes, once the namespace it names is read. + * + * Three namespaces are assignable and they are the same three a reference can read from, so + * a value a hook sets is reachable afterwards by the name it was set under. The fourth + * spelling, the deprecated one, writes where the newer one does — the rename applies to + * assignment targets as much as to reads. + */ +type HookTarget = "args" | "instance" | "params"; + +const TARGETS: Record<string, HookTarget> = { + args: "args", + compile_params: "instance", + instance: "instance", + params: "params", +}; + +/** What a hook produced, ready to be folded into the scope everything after it reads. */ +export type HookValues = { args: Record<string, string>; instance: Record<string, string> } & { + params: Record<string, string>; +}; + +export type RunHookResult = { ok: false; reason: string } | { ok: true; values: HookValues }; + +/** The hook an action runs once every input is resolved and before anything is built. */ +export function actionHook(action: NormalisedAction): Record<string, unknown> | undefined { + return asRecord(asRecord(action.node.on_pre_broadcast)?.set); +} + +/** The hook one input runs as soon as that input is resolved. */ +export function inputHook(input: Record<string, unknown>): Record<string, unknown> | undefined { + return asRecord(asRecord(input.on_resolved)?.set); +} + +/** + * Runs one hook's assignments, in the order the document writes them. + * + * **Order is the contract, not an implementation detail.** The format says assignments run + * in declaration order and that a later one may read an earlier one's result, so each + * assignment is evaluated against a scope that already carries everything set before it. + * Evaluating them together against one frozen scope would silently produce a different + * transaction for a document that reads its own earlier line. + * + * **A target is a namespace and a name.** `instance.X` writes a field of the deployment, + * `params.X` an action parameter, `args.X` an argument. A target naming anything else is + * refused rather than dropped: a hook that silently sets nothing is a document whose later + * lines read a value that was never written. + */ +export function runHook( + set: Record<string, unknown>, + scope: ReferenceScope, + notes?: NormalisationNote[], +): RunHookResult { + const values: HookValues = { args: {}, instance: {}, params: {} }; + let running = scope; + + for (const [target, expression] of Object.entries(set)) { + const split = /^(?<namespace>[A-Za-z_][A-Za-z0-9_]*)\.(?<name>[A-Za-z_][A-Za-z0-9_]*)$/.exec( + target, + ); + const namespace = split?.groups?.namespace; + const name = split?.groups?.name; + + if (!namespace || !name) { + return { ok: false, reason: `A hook assigns to "${target}", which names no value.` }; + } + + const lane = TARGETS[namespace]; + + if (!lane) { + return { + ok: false, + reason: `A hook assigns to "${target}", and "${namespace}" is not something this runtime can set.`, + }; + } + + if (typeof expression !== "string") { + return { ok: false, reason: `The hook's value for ${target} is not an expression.` }; + } + + const evaluated = evaluateExpression(expression, "amount", running, notes); + + if (!evaluated.ok) { + return { ok: false, reason: `Setting ${target}: ${evaluated.reason}` }; + } + + const value = String(evaluated.value); + + values[lane][name] = value; + running = foldInto(running, lane, name, value); + } + + return { ok: true, values }; +} + +/** + * The scope one input's own hook reads. + * + * Inside `on_resolved`, two bare words mean this input rather than something in scope: + * `asset` is the asset it holds — the one it just issued, when it issued one — and + * `reissuance_token` the token that issuance produced. They are bare rather than prefixed + * because the input naming them is the input being resolved, so there is nothing to + * qualify them with. + */ +export function inputHookScope( + scope: ReferenceScope, + self: Record<string, unknown>, +): ReferenceScope { + const bare: Record<string, unknown> = {}; + + for (const key of ["asset", "reissuance_token"]) { + if (self[key] !== undefined) { + bare[key] = self[key]; + } + } + + return { ...scope, params: { ...scope.params, ...bare } }; +} + +/** Folds every hook's output back into one scope, with the deployment and parameters merged. */ +export function withHookValues(scope: ReferenceScope, values: HookValues): ReferenceScope { + return { + ...scope, + args: { ...scope.args, ...values.args }, + instance: { ...scope.instance, ...values.instance }, + params: { ...scope.params, ...values.params }, + }; +} + +function foldInto( + scope: ReferenceScope, + lane: HookTarget, + name: string, + value: string, +): ReferenceScope { + return { ...scope, [lane]: { ...scope[lane], [name]: value } }; +} diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index f94e8ca..a9e36ea 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -12,6 +12,7 @@ import { asArray, asRecord } from "../document/json"; import { findAction, type NormalisationNote, + type NormalisedAction, normaliseInstance, normaliseManifest, } from "../document/normalise"; @@ -19,6 +20,13 @@ import type { ReferenceScope } from "../document/references"; import { buildMode, type RejectToken, refuseUnsupported } from "../document/refuse"; import { type ConstructFinding, ignored, inspectConstructs } from "../document/registry"; import { covenantSites } from "../document/sites"; +import { + actionHook, + inputHook, + inputHookScope, + runHook, + withHookValues, +} from "../evaluation/hooks"; import { type InputRule, resolveInputRules } from "../evaluation/inputRules"; import { planAction } from "../evaluation/plan"; import { checkValidations } from "../evaluation/validate"; @@ -250,7 +258,7 @@ export async function reviewManifestAction( return { reason: created.reason, refused: true, reject: "document-fault" }; } - const scope: ReferenceScope = { + let scope: ReferenceScope = { inputs, instance: created ? { ...deployment.instance.fields, ...created.instance.fields } @@ -325,7 +333,13 @@ export async function reviewManifestAction( } if (onChain.amountSats !== undefined && site.id) { - inputs[site.id] = { amount_sat: BigInt(onChain.amountSats) }; + // The asset is recorded beside the amount because an input's own hook reads it under + // the bare name `asset`, and a hook that could not see it would set a field of the + // deployment from nothing. + inputs[site.id] = { + amount_sat: BigInt(onChain.amountSats), + ...(onChain.rawAssetId === undefined ? {} : { asset: onChain.rawAssetId }), + }; } if (onChain.amountSats === undefined || onChain.rawAssetId === undefined) { @@ -359,6 +373,18 @@ export async function reviewManifestAction( }); } + // Hooks run after every input is resolved and before anything is built, which is what + // makes them able to say what an input turned out to hold. An input's own hook goes first + // and in declaration order, then the action's, because a document's later line may read + // what an earlier one set — and every output amount and validation below reads the result. + const hooked = runActionHooks(action, scope, notes); + + if (!hooked.ok) { + return { reason: hooked.reason, refused: true, reject: "document-fault" }; + } + + scope = hooked.scope; + let feeRateSatsPerKvb: number; try { @@ -532,3 +558,48 @@ function declaredParamTypes(action: Record<string, unknown>): Record<string, str return types; } + +/** + * Runs every hook this action declares, in the order the format defines. + * + * Each input's own hook first — in declaration order, each against a scope carrying what the + * hooks before it set — and then the action's own. An input's hook reads two bare names that + * mean the input being resolved rather than anything in scope, so it is given a scope of its + * own rather than the shared one. + */ +function runActionHooks( + action: NormalisedAction, + scope: ReferenceScope, + notes: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; scope: ReferenceScope } { + let running = scope; + + for (const entry of asArray(action.node.inputs)) { + const declared = asRecord(entry); + const set = declared && inputHook(declared); + + if (!declared || !set) { + continue; + } + + const id = typeof declared.id === "string" ? declared.id : undefined; + const resolved = id ? (running.inputs?.[id] ?? {}) : {}; + const ran = runHook(set, inputHookScope(running, resolved), notes); + + if (!ran.ok) { + return ran; + } + + running = withHookValues(running, ran.values); + } + + const set = actionHook(action); + + if (!set) { + return { ok: true, scope: running }; + } + + const ran = runHook(set, running, notes); + + return ran.ok ? { ok: true, scope: withHookValues(running, ran.values) } : ran; +} From 4e44907fa436c7ff144730cdeca4787eddd6e016 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 17:16:39 +0300 Subject: [PATCH 106/124] fix(tx-manifest): a top-level contract source decides nothing, so it stops refusing --- packages/tx-manifest/src/document/registry.ts | 8 ++++++- packages/tx-manifest/src/generations.test.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index a0935f1..b6f2b46 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -242,7 +242,13 @@ const SITES = { // already acts on. simplicity_hl: READ, simplicity_hl_version: READ, - source: UNIMPLEMENTED, + // One line in the published specification — "relative path to the top-level .simf + // file" — and nothing anywhere says what a runtime does with it. The newer schema + // dropped it from the top level entirely, the reference implementation reads no such + // field, and no published manifest carries one: a covenant's source is named on the + // covenant, where it decides an address. So it decides nothing here, and refusing a + // document for carrying it would be refusing for a field the format has abandoned. + source: UNREAD, utxo_types: READ, }, unknownIsLoadBearing: true, diff --git a/packages/tx-manifest/src/generations.test.ts b/packages/tx-manifest/src/generations.test.ts index 30a053d..21b4a21 100644 --- a/packages/tx-manifest/src/generations.test.ts +++ b/packages/tx-manifest/src/generations.test.ts @@ -15,6 +15,7 @@ import p2pk from "./__fixtures__/p2pk.manifest.json"; import zeroconf from "./__fixtures__/zeroconf.manifest.json"; import { normaliseManifest } from "./document/normalise"; import { parseReference } from "./document/references"; +import { inspectConstructs, loadBearing } from "./document/registry"; // Both criteria this file answers are about the published corpus rather than about // documents written to suit the reader, so every assertion below counts something in @@ -163,3 +164,24 @@ describe("both reference spellings are live in the published corpus", () => { expect(current).toMatchObject({ form: "instance", name: "OWNER" }); }); }); + +describe("a field the format has abandoned decides nothing", () => { + // The published specification lists a top-level path to a contract source in one line and + // says nothing about what a runtime does with it. The newer schema dropped it, the + // reference implementation reads no such field, and no published manifest carries one. + test("no published manifest carries a top-level contract source", () => { + const carrying = Object.entries(ALL) + .filter(([, document]) => typeof document.source === "string") + .map(([name]) => name); + + expect(carrying).toEqual([]); + }); + + test("and a document that carries one is read rather than refused", () => { + const withSource = { ...(ALL.p2pk as Record<string, unknown>), source: "./p2pk.simf" }; + const { manifest } = normaliseManifest(withSource); + const blocking = loadBearing(inspectConstructs(manifest)).map((finding) => finding.key); + + expect(blocking).not.toContain("source"); + }); +}); From 3a35dbd473ed83756064efcf30a31b2be2c1d8ec Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 17:54:11 +0300 Subject: [PATCH 107/124] feat(tx-manifest): create the asset an input issues, from the output it commits to spending The asset id is a function of the transaction output the issuing input spends, so that output is settled before the hooks that read the asset run and is withheld from the later funding selection. Derivation checked against four assets Liquid already holds rather than against this implementation's own output. Two defects surfaced on the way and are fixed here: a hook could only assign a number, which refused the one assignment every issuance in the corpus writes; and an op_return output was paid to the wallet's own script, dropping the bytes the document published. --- .../tx-manifest/src/chain/issuance.test.ts | 123 +++++++++ packages/tx-manifest/src/chain/issuance.ts | 159 +++++++++++ packages/tx-manifest/src/corpus.test.ts | 1 - packages/tx-manifest/src/currency.test.ts | 2 - .../tx-manifest/src/document/references.ts | 13 + packages/tx-manifest/src/document/registry.ts | 5 +- packages/tx-manifest/src/evaluation/hooks.ts | 39 ++- .../src/evaluation/issuance.test.ts | 252 ++++++++++++++++++ .../tx-manifest/src/evaluation/issuance.ts | 187 +++++++++++++ .../tx-manifest/src/review/coinSelection.ts | 2 +- packages/tx-manifest/src/review/index.test.ts | 117 ++++++++ packages/tx-manifest/src/review/index.ts | 183 ++++++++++++- 12 files changed, 1058 insertions(+), 25 deletions(-) create mode 100644 packages/tx-manifest/src/chain/issuance.test.ts create mode 100644 packages/tx-manifest/src/chain/issuance.ts create mode 100644 packages/tx-manifest/src/evaluation/issuance.test.ts create mode 100644 packages/tx-manifest/src/evaluation/issuance.ts diff --git a/packages/tx-manifest/src/chain/issuance.test.ts b/packages/tx-manifest/src/chain/issuance.test.ts new file mode 100644 index 0000000..bd055a1 --- /dev/null +++ b/packages/tx-manifest/src/chain/issuance.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; + +import { assetFromEntropy, deriveNewIssuance } from "./issuance"; + +/** + * Assets that exist on Liquid, and the outputs they were issued from. + * + * The derivation is nowhere in the format's own documents, so an expectation written from + * this implementation would prove only that it is consistent with itself. Each case below is + * one asset the chain already carries: its issuance outpoint, the issuer contract that + * issuance committed to, and the asset and reissuance-token ids that came out. Anything but + * the exact rule Elements uses reproduces none of them. + * + * Read on 2026-08-13 from Blockstream's Liquid Esplora, `GET /liquid/api/asset/<id>`, which + * reports each asset's `issuance_prevout`, `contract_hash` and `reissuance_token`. + */ +const ON_CHAIN = [ + { + asset: "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2", + contractHash: "3c7f0a53c2ff5b99590620d7f6604a7a3a7bfbaaa6aa61f7bfc7833ca03cde82", + name: "Tether USD", + reissuanceToken: "59fe4d2127ba9f16bd6850a3e6271a166e7ed2e1669f6c107d655791c94ee98f", + txid: "9596d259270ef5bac0020435e6d859aea633409483ba64e232b8ba04ce288668", + vout: 0, + }, + { + asset: "123465c803ae336c62180e52d94ee80d80828db54df9bedbb9860060f49de2eb", + contractHash: "d6cb01732239e8c317699c33ef525a8a1419ebf9a2ad318edbf8135f1665a773", + name: "Scamcoinbot token", + reissuanceToken: "2f7179e260a8046f02be25dec6abcf0a2c1bd3e6e13dd29ed67570e1e71a55b7", + txid: "fc2535f2e4fc2ef1d19b832248e3edc2c3f4c4e3ee9c2bc51777bd738a6f9582", + // The index is part of what is hashed, so at least one case has to be issued from + // somewhere other than the first output or a reader of the index proves nothing. + vout: 10, + }, + { + asset: "4d4354944366ea1e33f27c37fec97504025d6062c551208f68597d1ed40ec53e", + contractHash: "56cbf179ec75145ef54d88ff50284175852f926bf2d8d06f3e2deedbdf623779", + name: "Magical Crypto Friends", + reissuanceToken: "bc1e0094f30bc863610baf601ede6b3dda5cdb1b7d1a7831c93f011282924da3", + txid: "839e819d74ac98110fce63a3dab3a1075bbddcad811e0e125641989581919ab0", + vout: 1, + }, + { + asset: "beebee1a548fbb20280e539b697de076d87859a25c2983ebc55f2d8bec40abc3", + contractHash: "6e8198a20900717b87437261967214e2af0bb4d73c1134580b25ec597887203a", + name: "Beebee", + reissuanceToken: "fc061c7585a4f166d251ef4f5afd7c63e33358582426f06070cfb286249926cb", + txid: "27e6bd36daef786775768a6b106053d0f2f10e03b6f278715931caa00662138d", + vout: 3, + }, +]; + +describe("the asset a first issuance creates", () => { + for (const known of ON_CHAIN) { + test(`is the one Liquid holds for ${known.name}`, () => { + const derived = deriveNewIssuance({ txid: known.txid, vout: known.vout }, known.contractHash); + + expect(derived?.asset).toBe(known.asset); + }); + + test(`carries ${known.name}'s reissuance token`, () => { + const derived = deriveNewIssuance({ txid: known.txid, vout: known.vout }, known.contractHash); + + expect(derived?.reissuanceToken).toBe(known.reissuanceToken); + }); + } + + // Every issuance a manifest declares commits to nothing, so the default is the case this + // wallet actually runs and it must be the empty commitment rather than a repeat of one. + test("commits to no issuer contract unless one is given", () => { + const [known] = ON_CHAIN; + + if (!known) { + throw new Error("no chain vectors"); + } + + const withoutContract = deriveNewIssuance({ txid: known.txid, vout: known.vout }); + const withZeroes = deriveNewIssuance({ txid: known.txid, vout: known.vout }, "0".repeat(64)); + + expect(withoutContract?.asset).toBe(withZeroes?.asset ?? ""); + expect(withoutContract?.asset).not.toBe(known.asset); + }); + + test("changes when the output it is issued from changes", () => { + const [known] = ON_CHAIN; + + if (!known) { + throw new Error("no chain vectors"); + } + + const first = deriveNewIssuance({ txid: known.txid, vout: 0 }); + const second = deriveNewIssuance({ txid: known.txid, vout: 1 }); + + expect(first?.asset).not.toBe(second?.asset ?? ""); + }); + + test("is not derivable from something that is not an outpoint", () => { + expect(deriveNewIssuance({ txid: "aabb", vout: 0 })).toBeUndefined(); + expect(deriveNewIssuance({ txid: "a".repeat(64), vout: -1 })).toBeUndefined(); + }); +}); + +describe("the asset a reissuance mints", () => { + // A reissuance has no outpoint to derive from: it mints the asset that already exists, + // which is why the entropy is the thing a protocol has to have kept. + test("is the same asset, from the entropy the first issuance left", () => { + const [known] = ON_CHAIN; + + if (!known) { + throw new Error("no chain vectors"); + } + + const first = deriveNewIssuance({ txid: known.txid, vout: known.vout }, known.contractHash); + + expect(assetFromEntropy(first?.entropy ?? "")).toBe(known.asset); + }); + + test("is not derivable from something that is not an entropy", () => { + expect(assetFromEntropy("")).toBeUndefined(); + expect(assetFromEntropy("zz".repeat(32))).toBeUndefined(); + }); +}); diff --git a/packages/tx-manifest/src/chain/issuance.ts b/packages/tx-manifest/src/chain/issuance.ts new file mode 100644 index 0000000..84774fd --- /dev/null +++ b/packages/tx-manifest/src/chain/issuance.ts @@ -0,0 +1,159 @@ +/** + * Deriving the asset an issuance creates. + * + * Neither the format's specification nor its standards draft says how the asset id is + * computed; both say only that it is, and that the result is readable afterwards. The rule + * belongs to Elements rather than to the format, so it is written here from what the chain + * does and checked against assets that exist on Liquid. + * + * Three facts decide everything below. An asset is a function of the transaction output the + * issuing input spends, which is why nothing here can run before that output is chosen. The + * hash is not the usual double SHA-256 but a single compression of two 32-byte halves with + * no padding, which Elements calls a fast merkle root and uses nowhere else a wallet meets. + * And every id is written in reverse of how it is serialised, the same way a transaction id + * is, so the boundary of this module converts and the middle of it does not. + */ + +import { SHA256, sha256 } from "@noble/hashes/sha2.js"; + +import { decodeHex, encodeHex } from "./bytes"; + +/** The output an issuing input spends, which is what its asset is derived from. */ +export type Outpoint = { txid: string; vout: number }; + +/** What one issuance produces, in the form ids are written and read. */ +export type DerivedIssuance = { + /** The asset the issuance creates. */ + asset: string; + /** + * What a later reissuance of this same asset is derived from. + * + * Kept because it is the only thing that survives the transaction: the outpoint is spent + * and cannot be asked again, so a protocol that ever reissues has to have recorded this. + */ + entropy: string; + /** + * The token that authorises reissuing this asset. + * + * Derived whether or not any is minted, because the format exposes it by name inside an + * input's own hook and does not condition that on the amount. This is the unblinded form; + * the chain derives a different id when the issuance is blinded, and this wallet builds + * explicit transactions. + */ + reissuanceToken: string; +}; + +/** + * The asset, token and entropy a first issuance on this outpoint produces. + * + * `contractHash` is the issuer contract the issuance commits to. Every asset in Liquid's + * public registry commits to one; a manifest declares no such thing at any position, so the + * commitment is empty here and saying that explicitly is the difference between a wallet + * that established there is no contract and one that never looked for it. + */ +export function deriveNewIssuance( + outpoint: Outpoint, + contractHash = ZERO_HASH, +): DerivedIssuance | undefined { + const spent = serialiseOutpoint(outpoint); + const contract = readId(contractHash); + + if (!spent || !contract) { + return undefined; + } + + // The outpoint is hashed the ordinary way, twice, and only the combining step is the + // unusual one. + const entropy = combine(sha256(sha256(spent)), contract); + + return { + asset: writeId(combine(entropy, ASSET)), + entropy: writeId(entropy), + reissuanceToken: writeId(combine(entropy, TOKEN)), + }; +} + +/** + * The asset a reissuance produces, from the entropy the first issuance left behind. + * + * Separate from {@link deriveNewIssuance} because a reissuance has no outpoint of its own to + * derive from — the asset it mints is the one that already exists, and the input it sits on + * spends the token rather than the origin. + */ +export function assetFromEntropy(entropy: string): string | undefined { + const bytes = readId(entropy); + + return bytes ? writeId(combine(bytes, ASSET)) : undefined; +} + +/** No issuer contract, which is what every issuance a manifest declares commits to. */ +const ZERO_HASH = "0".repeat(64); + +/** The second half Elements combines an entropy with to reach the asset itself. */ +const ASSET = new Uint8Array(32); + +/** The second half that reaches the reissuance token instead, in its unblinded form. */ +const TOKEN = Uint8Array.from([1, ...Array.from({ length: 31 }, () => 0)]); + +/** + * Elements' fast merkle root of two 32-byte values. + * + * One SHA-256 compression of the pair as a single block, taken before the padding and length + * that finish an ordinary hash. A plain `sha256(left || right)` is a different value, and + * one that would look entirely reasonable in a test that only checked its own output. + */ +function combine(left: Uint8Array, right: Uint8Array): Uint8Array { + const block = new Uint8Array(64); + + block.set(left, 0); + block.set(right, 32); + + return new Midstate().compress(block); +} + +/** + * SHA-256 stopped after one block, which the audited implementation exposes only to itself. + * + * Subclassed rather than reimplemented: the compression function is the whole of the hash, + * and a hand-written copy of it would be the least reviewed cryptography in this wallet. + */ +class Midstate extends SHA256 { + compress(block: Uint8Array): Uint8Array { + this.process(new DataView(block.buffer, block.byteOffset, block.byteLength), 0); + + const out = new Uint8Array(32); + const writer = new DataView(out.buffer); + + this.get().forEach((word, at) => writer.setUint32(at * 4, word >>> 0, false)); + + return out; + } +} + +/** The 36 bytes an outpoint occupies: the transaction as serialised, then the index. */ +function serialiseOutpoint(outpoint: Outpoint): Uint8Array | undefined { + const transaction = readId(outpoint.txid); + + if (!transaction || !Number.isInteger(outpoint.vout) || outpoint.vout < 0) { + return undefined; + } + + const bytes = new Uint8Array(36); + + bytes.set(transaction, 0); + new DataView(bytes.buffer).setUint32(32, outpoint.vout, true); + + return bytes; +} + +/** An id as it is written turned into the bytes it is made of, which are the other way round. */ +function readId(hex: string): Uint8Array | undefined { + const bytes = decodeHex(hex); + + return bytes?.length === 32 ? bytes.toReversed() : undefined; +} + +/** The same conversion back, because an id leaves here in the form everything else reads. */ +function writeId(bytes: Uint8Array): string { + return encodeHex(bytes.toReversed()); +} diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index bf5d466..92caf8a 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -234,7 +234,6 @@ describe("what the corpus uses and this wallet does not read", () => { expect([...unread].toSorted()).toEqual([ "confidential", "default", - "issuance", "required_index", "simplicity_type", "source", diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts index 27de0af..c3009c6 100644 --- a/packages/tx-manifest/src/currency.test.ts +++ b/packages/tx-manifest/src/currency.test.ts @@ -101,7 +101,6 @@ describe("the protocols as their authors publish them now", () => { "compute", "confidential", "default", - "issuance", "required_index", "simplicity_type", "value", @@ -110,7 +109,6 @@ describe("the protocols as their authors publish them now", () => { "compute", "confidential", "default", - "issuance", "required_index", "simplicity_type", "value", diff --git a/packages/tx-manifest/src/document/references.ts b/packages/tx-manifest/src/document/references.ts index cb445e7..75e6632 100644 --- a/packages/tx-manifest/src/document/references.ts +++ b/packages/tx-manifest/src/document/references.ts @@ -72,6 +72,7 @@ export type ReferenceSiteKind = | "compileParam" | "destination" | "expression" + | "issuedAmount" | "witnessKey"; const SITES: Record<ReferenceSiteKind, { accepts: ReferenceForm[]; describes: string }> = { @@ -92,6 +93,18 @@ const SITES: Record<ReferenceSiteKind, { accepts: ReferenceForm[]; describes: st accepts: ["fee", "instance", "params", "args", "input-attribute", "bare"], describes: "an expression", }, + /** + * How many units an issuance creates, which is not an amount anyone pays. + * + * The fee is absent for the same reason it is absent from a compile parameter: the fee + * comes from the shape of the transaction, and how much of an asset exists cannot depend + * on what it costs to say so. An attribute of a resolved input is absent because the + * issuance is what makes that input's asset what it is. + */ + issuedAmount: { + accepts: ["instance", "params", "args", "bare"], + describes: "an issued amount", + }, /** The key a witness is produced from. */ witnessKey: { accepts: ["instance", "params", "args", "bare"], diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index b6f2b46..f622bdf 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -199,7 +199,10 @@ const SITES = { description: SHOWN, from_address: READ, id: READ, - issuance: UNIMPLEMENTED, + // Read for the one kind the wallet can carry out. A reissuance and a minted + // reissuance token are refused by name where the block is resolved, because both + // depend on what the request carries rather than on what the document says. + issuance: READ, on_resolved: READ, // The action tolerates this input's absence. The wallet includes what it is given // and never drops one, which is inside what the declaration permits. diff --git a/packages/tx-manifest/src/evaluation/hooks.ts b/packages/tx-manifest/src/evaluation/hooks.ts index 6fe716c..13789e3 100644 --- a/packages/tx-manifest/src/evaluation/hooks.ts +++ b/packages/tx-manifest/src/evaluation/hooks.ts @@ -1,6 +1,6 @@ import { asRecord } from "../document/json"; import type { NormalisationNote, NormalisedAction } from "../document/normalise"; -import type { ReferenceScope } from "../document/references"; +import { type ReferenceScope, resolveReference } from "../document/references"; import { evaluateExpression } from "./evaluate"; /** @@ -83,21 +83,48 @@ export function runHook( return { ok: false, reason: `The hook's value for ${target} is not an expression.` }; } - const evaluated = evaluateExpression(expression, "amount", running, notes); + const evaluated = valueOf(expression, running, notes); if (!evaluated.ok) { return { ok: false, reason: `Setting ${target}: ${evaluated.reason}` }; } - const value = String(evaluated.value); - - values[lane][name] = value; - running = foldInto(running, lane, name, value); + values[lane][name] = evaluated.value; + running = foldInto(running, lane, name, evaluated.value); } return { ok: true, values }; } +/** + * What one assignment comes to: a number it works out, or a value it names. + * + * Arithmetic is tried first, and everything the corpus's action-level hooks write is + * arithmetic. What is not is the reason an input has a hook at all: `"asset"` is the asset + * that input just created, a thirty-two byte id, and there is no number it could be. A hook + * that could only produce numbers would refuse the very assignment protocols write hooks for. + * + * The arithmetic failure is the one reported when neither works, because a value that names + * nothing is almost always a mistyped expression rather than a mistyped name. + */ +function valueOf( + expression: string, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: string } { + const evaluated = evaluateExpression(expression, "amount", scope, notes); + + if (evaluated.ok) { + return { ok: true, value: String(evaluated.value) }; + } + + const named = resolveReference(expression, "expression", scope, notes); + + return named.ok && typeof named.value === "string" + ? { ok: true, value: named.value } + : { ok: false, reason: evaluated.reason }; +} + /** * The scope one input's own hook reads. * diff --git a/packages/tx-manifest/src/evaluation/issuance.test.ts b/packages/tx-manifest/src/evaluation/issuance.test.ts new file mode 100644 index 0000000..409a267 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/issuance.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; + +import lending from "../__fixtures__/lending.manifest.json"; +import lendingV2 from "../__fixtures__/lending_v2.manifest.json"; +import lendingV3 from "../__fixtures__/lending_v3.manifest.json"; +import { asArray, asRecord } from "../document/json"; +import { normaliseManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import { declaredIssuance, issuanceAttributes, resolveIssuance } from "./issuance"; + +/** The three protocols in the corpus that create assets, across their generations. */ +const ISSUING = { lending, lending_v2: lendingV2, lending_v3: lendingV3 } as unknown as Record< + string, + Record<string, unknown> +>; + +/** An output of this wallet's, standing for whichever one the review commits to. */ +const OUTPOINT = { txid: "c".repeat(64), vout: 0 }; + +/** Every issuance the corpus declares, with the input carrying it. */ +function issuancesIn(document: Record<string, unknown>) { + const { manifest } = normaliseManifest(document); + const found: { declared: Record<string, unknown>; id: string }[] = []; + + for (const action of manifest.actions) { + for (const entry of asArray(action.node.inputs)) { + const input = asRecord(entry); + const declared = input && declaredIssuance(input); + + if (input && declared) { + found.push({ declared, id: typeof input.id === "string" ? input.id : "(unnamed)" }); + } + } + } + + return found; +} + +/** The values the two formula-bearing issuances in the corpus read, under both spellings. */ +const SCOPE: ReferenceScope = { + instance: { FIRST_PARAMETERS_ENCODED: "5000", SECOND_PARAMETERS_ENCODED: "7000" }, + params: {}, +}; + +describe("the issuances real protocols declare", () => { + // Eleven across three generations, and every one of them creates rather than reissues. + // A branch for the other kind would be a branch nothing in the published corpus reaches. + test("are all first issuances", () => { + const kinds = new Set<unknown>(); + + for (const name of Object.keys(ISSUING)) { + for (const { declared } of issuancesIn(ISSUING[name]!)) { + kinds.add(declared.kind); + } + } + + expect([...kinds]).toEqual(["new"]); + }); + + test("are each resolved to an asset", () => { + for (const name of Object.keys(ISSUING)) { + const found = issuancesIn(ISSUING[name]!); + + expect({ [name]: found.length }).not.toEqual({ [name]: 0 }); + + for (const { declared, id } of found) { + const resolved = resolveIssuance({ declared, id, outpoint: OUTPOINT }, SCOPE, []); + + expect({ [`${name} / ${id}`]: resolved.ok }).toEqual({ [`${name} / ${id}`]: true }); + } + } + }); + + // Two of them say how much they create with an expression rather than a number, and the + // two generations spell the same lookup differently. Reading one and not the other would + // leave the older protocol issuing an amount nobody worked out. + test("read an amount written as an expression, in either generation's spelling", () => { + const amounts: Record<string, bigint> = {}; + + for (const name of ["lending", "lending_v2"]) { + for (const { declared, id } of issuancesIn(ISSUING[name]!)) { + if (typeof declared.asset_amount_sat !== "string") { + continue; + } + + const resolved = resolveIssuance({ declared, id, outpoint: OUTPOINT }, SCOPE, []); + + if (resolved.ok) { + amounts[`${name} / ${id}`] = resolved.issuance.assetAmountSats; + } + } + } + + expect(amounts).toEqual({ + "lending / first_params_issuance_input": 5000n, + "lending / second_params_issuance_input": 7000n, + "lending_v2 / first_params_issuance_input": 5000n, + "lending_v2 / second_params_issuance_input": 7000n, + }); + }); + + // The one the wallet does not choose an output for: its input spends a covenant the state + // file named, so the asset is derived from that outpoint and not from the wallet's coins. + test("include one issued from a covenant rather than from the wallet's own output", () => { + const covenantSourced = issuancesIn(ISSUING.lending_v3!).filter(({ id }) => + id.endsWith("covenant_in"), + ); + + expect(covenantSourced.length).toBe(1); + }); +}); + +describe("what an issued asset is derived from", () => { + test("is the output the input spends, so two inputs never create one asset", () => { + const declared = { asset_amount_sat: 1, inflation_amount_sat: 0, kind: "new" }; + const first = resolveIssuance({ declared, id: "a", outpoint: OUTPOINT }, SCOPE, []); + const second = resolveIssuance( + { declared, id: "b", outpoint: { ...OUTPOINT, vout: 1 } }, + SCOPE, + [], + ); + + expect(first.ok && second.ok).toBe(true); + + if (!first.ok || !second.ok) { + return; + } + + expect(first.issuance.asset).not.toBe(second.issuance.asset); + }); + + // Inside the input's own hook, `asset` is what the input creates rather than what the + // output it spends was holding. A hook reading the second would write the funding asset + // into a field the covenant is then compiled with. + test("is what the input's own hook reads under the bare name", () => { + const resolved = resolveIssuance( + { + declared: { asset_amount_sat: 2, inflation_amount_sat: 0, kind: "new" }, + id: "factory_issuance_input", + outpoint: OUTPOINT, + }, + SCOPE, + [], + ); + + expect(resolved.ok).toBe(true); + + if (!resolved.ok) { + return; + } + + expect(issuanceAttributes(resolved.issuance)).toEqual({ + asset: resolved.issuance.asset, + reissuance_token: resolved.issuance.reissuanceToken, + }); + }); +}); + +describe("the issuances this wallet refuses", () => { + test("a reissuance, naming what it has nothing to derive from", () => { + const resolved = resolveIssuance( + { + declared: { asset_amount_sat: 1, kind: "reissue" }, + id: "top_up", + outpoint: OUTPOINT, + }, + SCOPE, + [], + ); + + expect(resolved.ok).toBe(false); + + if (resolved.ok) { + return; + } + + expect(resolved.reject).toBe("unimplemented-construct"); + expect(resolved.reason).toContain("entropy"); + }); + + // A token has to be held confidentially on Liquid and every value this wallet builds is + // explicit, so minting one would produce a token nobody could spend. + test("a minted reissuance token", () => { + const resolved = resolveIssuance( + { + declared: { asset_amount_sat: 1, inflation_amount_sat: 1, kind: "new" }, + id: "factory_issuance_input", + outpoint: OUTPOINT, + }, + SCOPE, + [], + ); + + expect(resolved.ok).toBe(false); + + if (resolved.ok) { + return; + } + + expect(resolved.reject).toBe("unimplemented-construct"); + }); + + test("a kind the format does not define", () => { + const resolved = resolveIssuance( + { + declared: { asset_amount_sat: 1, kind: "burn" }, + id: "an_input", + outpoint: OUTPOINT, + }, + SCOPE, + [], + ); + + expect(resolved.ok && "unreachable").toBe(false); + + if (resolved.ok) { + return; + } + + expect(resolved.reject).toBe("document-fault"); + }); + + test("an amount that creates nothing", () => { + const resolved = resolveIssuance( + { + declared: { asset_amount_sat: 0, kind: "new" }, + id: "an_input", + outpoint: OUTPOINT, + }, + SCOPE, + [], + ); + + expect(resolved.ok).toBe(false); + }); + + // How much of an asset exists cannot depend on what the transaction costs to send: the + // fee comes from the shape of the transaction the amount appears in. + test("an amount worked out from the fee", () => { + const resolved = resolveIssuance( + { + declared: { asset_amount_sat: "fee", kind: "new" }, + id: "an_input", + outpoint: OUTPOINT, + }, + { ...SCOPE, fee: 500n }, + [], + ); + + expect(resolved.ok).toBe(false); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/issuance.ts b/packages/tx-manifest/src/evaluation/issuance.ts new file mode 100644 index 0000000..80f431e --- /dev/null +++ b/packages/tx-manifest/src/evaluation/issuance.ts @@ -0,0 +1,187 @@ +import { type DerivedIssuance, deriveNewIssuance, type Outpoint } from "../chain/issuance"; +import { asRecord } from "../document/json"; +import type { NormalisationNote } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import type { RejectToken } from "../document/refuse"; +import { evaluateExpression } from "./evaluate"; + +/** + * What one input's issuance block asks for, once its amounts are worked out. + * + * The kind is narrowed to the one this wallet can carry out. A reissuance is refused rather + * than represented, because a value that stands for something the runtime will not do is a + * value some later branch treats as a case to handle. + */ +export type IssuanceRequest = { + /** Units of the asset to create. */ + assetAmountSats: bigint; + /** Units of the token that would authorise reissuing it. Always zero here; see below. */ + inflationAmountSats: bigint; + kind: "new"; +}; + +/** One input's issuance, worked out and derived against the output that input spends. */ +export type PlannedIssuance = DerivedIssuance & + IssuanceRequest & { + /** The manifest's id for the input carrying it. */ + inputId: string; + /** The output the asset is derived from, which is what makes the id what it is. */ + outpoint: Outpoint; + }; + +export type IssuanceResult = + | { issuance: PlannedIssuance; ok: true } + | { ok: false; reason: string; reject: RejectToken }; + +/** The issuance an input declares, if it declares one. */ +export function declaredIssuance( + input: Record<string, unknown>, +): Record<string, unknown> | undefined { + return asRecord(input.issuance); +} + +/** + * Works out what an input's issuance creates, and from which of the wallet's outputs. + * + * Every refusal here is about what this wallet will not mint rather than about a malformed + * document, which is why they name the construct: a protocol whose asset can only exist as a + * blinded one is not a protocol this wallet builds badly, it is one it does not build. + */ +export function resolveIssuance( + input: { + declared: Record<string, unknown>; + id: string; + outpoint: Outpoint; + }, + scope: ReferenceScope, + notes?: NormalisationNote[], +): IssuanceResult { + const kind = input.declared.kind; + + // The format defines two kinds and this wallet carries out one. A reissuance mints an + // asset that already exists, so it is derived from the entropy the first issuance left + // behind rather than from anything in this transaction — and that entropy reaches a + // request only on a supplied input, which this wallet does not read. Deriving it from + // this input's outpoint instead would mint a different asset under the protocol's name. + if (kind === "reissue") { + return { + ok: false, + reason: + `Input ${input.id} reissues an asset, and this wallet has nothing to derive it from: ` + + "the entropy of the original issuance is not part of what a site sends it.", + reject: "unimplemented-construct", + }; + } + + if (kind !== "new") { + return { + ok: false, + reason: + `Input ${input.id} declares an issuance of kind ${JSON.stringify(kind)}, and the ` + + 'format defines "new" and "reissue".', + reject: "document-fault", + }; + } + + const assetAmount = amountOf(input.declared.asset_amount_sat, scope, notes); + + if (!assetAmount.ok) { + return { + ok: false, + reason: `Input ${input.id} does not say how much it issues: ${assetAmount.reason}`, + reject: "document-fault", + }; + } + + if (assetAmount.value <= 0n) { + return { + ok: false, + reason: `Input ${input.id} issues ${assetAmount.value} units, which creates no asset.`, + reject: "document-fault", + }; + } + + const inflation = + input.declared.inflation_amount_sat === undefined + ? { ok: true as const, value: 0n } + : amountOf(input.declared.inflation_amount_sat, scope, notes); + + if (!inflation.ok) { + return { + ok: false, + reason: `Input ${input.id} does not say how many reissuance tokens it mints: ${inflation.reason}`, + reject: "document-fault", + }; + } + + // Liquid requires a reissuance token to be held confidentially, and this wallet builds + // explicit transactions — a covenant cannot introspect a blinded value, which is why the + // whole path is explicit. Minting one anyway would produce a token nobody can spend. + if (inflation.value !== 0n) { + return { + ok: false, + reason: + `Input ${input.id} mints ${inflation.value} reissuance tokens, which have to be held ` + + "confidentially, and this wallet builds transactions whose values are all explicit.", + reject: "unimplemented-construct", + }; + } + + const derived = deriveNewIssuance(input.outpoint); + + if (!derived) { + return { + ok: false, + reason: + `Input ${input.id} issues an asset from ${input.outpoint.txid}:${input.outpoint.vout}, ` + + "which is not an output this wallet can read.", + reject: "document-fault", + }; + } + + return { + issuance: { + ...derived, + assetAmountSats: assetAmount.value, + inflationAmountSats: 0n, + inputId: input.id, + kind: "new", + outpoint: input.outpoint, + }, + ok: true, + }; +} + +/** + * What an issuance says about its own values, for a later expression to read. + * + * Two bare names mean the input being resolved, and this is what they resolve to. The asset + * is the issued one rather than the one the spent output held: an issuing input's `asset` is + * what it creates, which is the whole reason a protocol writes the hook. + */ +export function issuanceAttributes(issuance: PlannedIssuance): Record<string, unknown> { + return { asset: issuance.asset, reissuance_token: issuance.reissuanceToken }; +} + +/** A literal count, or an expression evaluated where the fee cannot reach. */ +function amountOf( + declared: unknown, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: bigint } { + if (typeof declared === "bigint") { + return { ok: true, value: declared }; + } + + if (typeof declared === "number") { + return Number.isSafeInteger(declared) + ? { ok: true, value: BigInt(declared) } + : { ok: false, reason: `${declared} is not a whole number of units.` }; + } + + if (typeof declared !== "string") { + return { ok: false, reason: "it is neither a number nor an expression." }; + } + + return evaluateExpression(declared, "issuedAmount", scope, notes); +} diff --git a/packages/tx-manifest/src/review/coinSelection.ts b/packages/tx-manifest/src/review/coinSelection.ts index a2fca3b..dbef9b1 100644 --- a/packages/tx-manifest/src/review/coinSelection.ts +++ b/packages/tx-manifest/src/review/coinSelection.ts @@ -89,7 +89,7 @@ export function selectCoins( } /** Amounts arrive as base-unit strings and stay exact; a double would round past 2^53. */ -function toSats(amount: string): bigint { +export function toSats(amount: string): bigint { try { return BigInt(amount); } catch { diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts index d6cbfca..6270d27 100644 --- a/packages/tx-manifest/src/review/index.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import groupedManifest from "../__fixtures__/p2pk-grouped.manifest.json"; import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; import type { TxOutAtOutPoint } from "../chain/chainRead"; +import { deriveNewIssuance } from "../chain/issuance"; import { txOutAt } from "../chain/txOut"; import { estimateFeeSats } from "../fee"; import type { ParsedLiquidProcessCtParams } from "../request/request"; @@ -572,3 +573,119 @@ describe("the mode a protocol declares reaches the compiler", () => { expect(declared.seen).not.toEqual(plain.seen); }); }); + +/** + * An action that creates an asset, in the shape the corpus writes one. + * + * The declaration and the hook are lifted from `lending_v3`'s `CreateFactory`, which mints + * an asset from a wallet output and records it in the deployment it is creating. What is + * added is a place a later line reads it from: an OP_RETURN carrying the asset id, which is + * how the same protocol publishes what it created. Read end to end, this is the whole claim + * — the wallet commits to one of its own outputs, derives the asset that output produces, + * and the document's own later line resolves it by name. + */ +function issuingManifest() { + const document = structuredClone(p2pkManifest) as unknown as Record<string, unknown>; + const actions = document.actions as Record<string, Record<string, unknown>>; + const pay = actions.Pay!; + const inputs = pay.inputs as Record<string, unknown>[]; + const outputs = pay.outputs as Record<string, unknown>[]; + + inputs[0]!.issuance = { asset_amount_sat: 2, inflation_amount_sat: 0, kind: "new" }; + inputs[0]!.on_resolved = { set: { "instance.MINTED_ASSET": "asset" } }; + outputs.push({ + data: { parts: [{ type: "bytes", value: "instance.MINTED_ASSET" }] }, + description: "The asset this action created, published for whoever indexes it.", + destination: { type: "op_return" }, + id: "minted_marker", + }); + + return document; +} + +const TWO_UTXOS = [ + { amount: "600000", spendable: true, txid: "d".repeat(64), txOut: "00", vout: 7 }, + { amount: "1000000", spendable: true, txid: "c".repeat(64), txOut: "00", vout: 0 }, +]; + +describe("an input that creates an asset", () => { + async function issuingReview() { + return reviewManifestAction(request({ manifest: issuingManifest() }), { + ...deps, + fundingUtxos: TWO_UTXOS, + readTxOut: readTxOut(UNSPENT_ELSEWHERE), + }); + } + + test("derives the asset from the wallet output it commits to spending", async () => { + const result = await issuingReview(); + + expect(isRefusal(result)).toBe(false); + + if (isRefusal(result)) { + return; + } + + const issued = result.issuances[0]; + + expect(result.issuances.length).toBe(1); + expect(issued?.inputId).toBe("funding_input"); + expect(issued?.assetAmountSats).toBe(2n); + // The asset is a statement about one output, so it has to be the derivation of the + // output this transaction actually spends rather than of any output the wallet holds. + expect(issued?.asset).toBe( + deriveNewIssuance({ txid: issued?.outpoint.txid ?? "", vout: issued?.outpoint.vout ?? 0 }) + ?.asset ?? "", + ); + }); + + // The output the asset is derived from has to be spent, exactly once. Left out, the id is + // for an asset that never exists; taken twice, there is no transaction at all. + test("spends that output once, and the selection does not offer it again", async () => { + const result = await issuingReview(); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + const issued = result.issuances[0]!; + const spending = result.selected.filter( + (utxo) => utxo.txid === issued.outpoint.txid && utxo.vout === issued.outpoint.vout, + ); + + expect(spending.length).toBe(1); + expect(result.selected[0]?.txid).toBe(issued.outpoint.txid); + }); + + test("makes the asset readable by name in a line written after it", async () => { + const result = await issuingReview(); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + const marker = result.outputs.find((output) => output.id === "minted_marker"); + + // The OP_RETURN carries the asset as bytes, so the id the hook read is in the script + // the transaction pays to — not merely in a value the review kept to itself. + expect(marker?.scriptPubKeyHex).toContain(result.issuances[0]!.asset); + // And it is the script the document asked for. Paid to the wallet instead, this output + // would carry nothing, pay nothing, and look exactly like a correct one from outside. + expect(marker?.scriptPubKeyHex.startsWith("6a")).toBe(true); + expect(marker?.scriptPubKeyHex).not.toBe(WALLET_SCRIPT); + }); + + test("refuses when the wallet has no output to derive an asset from", async () => { + const result = await reviewManifestAction(request({ manifest: issuingManifest() }), { + ...deps, + fundingUtxos: [], + readTxOut: readTxOut(UNSPENT_ELSEWHERE), + }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reject).toBe("shortfall"); + } + }); +}); diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index a9e36ea..b4edecc 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -28,12 +28,18 @@ import { withHookValues, } from "../evaluation/hooks"; import { type InputRule, resolveInputRules } from "../evaluation/inputRules"; +import { + declaredIssuance, + issuanceAttributes, + type PlannedIssuance, + resolveIssuance, +} from "../evaluation/issuance"; import { planAction } from "../evaluation/plan"; import { checkValidations } from "../evaluation/validate"; import { estimateFeeSats } from "../fee"; import type { ParsedLiquidProcessCtParams } from "../request/request"; import { resolveActionRequirements } from "../request/requirements"; -import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; +import { type CoinSelection, type SelectableUtxo, selectCoins, toSats } from "./coinSelection"; /** * What the wallet established for itself about one covenant this action touches. @@ -114,6 +120,14 @@ export type ManifestReview = { * silently is indistinguishable from one that missed it. */ ignoredConstructs: ConstructFinding[]; + /** + * The assets this action creates, each with the output of the wallet's it is derived from. + * + * An asset id is a function of the output the issuing input spends, so the two are kept + * together: separated, nothing downstream could tell whether the id belongs to the output + * the transaction actually spends or to one considered and dropped. + */ + issuances: PlannedIssuance[]; /** * Everything the person is shown, with every value's origin attached. * @@ -223,6 +237,8 @@ export async function reviewManifestAction( const covenantInputs: ReviewedCovenantInput[] = []; /** What each covenant input actually holds, read from the chain rather than told. */ const inputs: Record<string, Record<string, unknown>> = {}; + /** Which output each input spends, for the ones whose identity an issuance depends on. */ + const spent = new Map<string, { txid: string; vout: number }>(); // The parameters a manifest works out for itself come first: a covenant compiled with // another covenant's hash needs that hash before its own address can be derived, and a @@ -312,6 +328,10 @@ export async function reviewManifestAction( }; } + if (site.id) { + spent.set(site.id, outpoint); + } + let onChain; try { @@ -373,6 +393,22 @@ export async function reviewManifestAction( }); } + // An asset an action creates is derived from the output its issuing input spends, so that + // output is settled here rather than at the selection below: an input's own hook reads the + // asset as soon as the input resolves, and an id derived from an output the wallet had not + // yet committed to spending would be an id for a different asset. + const issued = resolveIssuances(action, { + fundingUtxos: input.fundingUtxos, + inputs, + notes, + scope, + spent, + }); + + if (!issued.ok) { + return { reason: issued.reason, refused: true, reject: issued.reject }; + } + // Hooks run after every input is resolved and before anything is built, which is what // makes them able to say what an input turned out to hold. An input's own hook goes first // and in declaration order, then the action's, because a document's later line may read @@ -415,9 +451,10 @@ export async function reviewManifestAction( { covenantInputs: covenantInputs.length, outputs: draft.plan.outputs.length, - // The wallet has not chosen its inputs yet, and one is the common case; a - // selection that takes more is priced below, before anything is committed to. - walletInputs: 1, + // The wallet has not chosen the rest of its inputs yet, and one is the common case; a + // selection that takes more is priced below, before anything is committed to. The + // outputs already committed to for an issuance are not a guess and are counted. + walletInputs: Math.max(1, issued.reserved.length), }, feeRateSatsPerKvb, ); @@ -456,10 +493,14 @@ export async function reviewManifestAction( // A covenant output pays the address the wallet derived, never one the request // supplied. There is no path from a site-supplied address to a transaction output. + // An op_return pays to the bytes the plan encoded: it is the output, and paying it to + // the wallet instead would drop what the protocol published and pay nothing to nobody. const scriptPubKeyHex = planned.target.kind === "covenant" ? covenantScripts.get(planned.target.utxoType) - : input.walletScriptPubKeyHex; + : planned.target.kind === "data" + ? planned.target.hex + : input.walletScriptPubKeyHex; if (!scriptPubKeyHex) { return { @@ -476,12 +517,13 @@ export async function reviewManifestAction( // A protocol requiring a specific address is usually requiring a specific key, and funding // it from whatever the wallet happens to hold builds a transaction it did not ask for. const pinned = inputRules.rules.find((rule) => rule.fromAddress !== undefined)?.fromAddress; - const fundable = + const fundable = ( pinned === undefined ? input.fundingUtxos - : input.fundingUtxos.filter((utxo) => utxo.scriptPubKeyHex === pinned); + : input.fundingUtxos.filter((utxo) => utxo.scriptPubKeyHex === pinned) + ).filter((utxo) => !issued.reserved.includes(utxo)); - if (pinned !== undefined && fundable.length === 0) { + if (pinned !== undefined && fundable.length === 0 && issued.reserved.length === 0) { return { reason: `This action must be funded from ${pinned}, and this wallet holds nothing there.`, refused: true, @@ -489,11 +531,33 @@ export async function reviewManifestAction( }; } - const selection: CoinSelection = selectCoins( - fundable, - plan.plan.fundingSats, - BigInt(Math.ceil(feeRateSatsPerKvb)), - ); + // An output committed to for an issuance was chosen before the action's address pin could + // be resolved, because the asset id depends on it and the hooks that read that id run + // first. Where the two disagree the action is refused rather than built from the other + // output: moving the issuance would mint a different asset than the one already computed. + const misplaced = + pinned === undefined + ? undefined + : issued.reserved.find((utxo) => utxo.scriptPubKeyHex !== pinned); + + if (misplaced) { + return { + reason: + `This action must be funded from ${pinned}, and the output it issues an asset from ` + + "is not there.", + refused: true, + reject: "no-funds-at-signing-address", + }; + } + + // What the outputs committed to for an issuance already bring, which the selection below + // does not have to find again. + const held = issued.reserved.reduce((total, utxo) => total + toSats(utxo.amount), 0n); + const outstanding = plan.plan.fundingSats - held; + const selection: CoinSelection = + outstanding > 0n + ? selectCoins(fundable, outstanding, BigInt(Math.ceil(feeRateSatsPerKvb))) + : { ok: true, selected: [], totalSats: held }; if (!selection.ok) { return { reason: selection.reason, refused: true, reject: "shortfall" }; @@ -507,11 +571,14 @@ export async function reviewManifestAction( estimatedFeeSats: estimatedFee, feeRateSatsPerKvb, ignoredConstructs: ignored(inspectConstructs(manifest)), + issuances: issued.issuances, normalisation: notes, outputs, inputRules: inputRules.rules, protocol: manifest.protocol ?? "", - selected: selection.selected, + // The outputs an issuance is derived from come first and in the order the action + // declares them, because each asset id is a statement about one of them. + selected: [...issued.reserved, ...selection.selected], }; return { @@ -526,6 +593,94 @@ export async function reviewManifestAction( /** Confirmation target for the fee estimate, in blocks. */ const FEE_TARGET_BLOCKS = 6; +type ResolvedIssuances = + | { issuances: PlannedIssuance[]; ok: true; reserved: SelectableUtxo[] } + | { ok: false; reason: string; reject: RejectToken }; + +/** + * Works out every asset this action creates, and which output each one is derived from. + * + * A covenant input already has an output: the state file named it and the chain confirmed + * what is there. An input the wallet funds does not, and this is where it gets one — the + * asset id is a function of that output, so choosing it later would mean deriving an id for + * an output the transaction might not spend. + * + * The chosen output is returned as reserved rather than merely noted. Everything after this + * treats the funding pool as what is left, because an output spent twice is not a + * transaction, and an issuance derived from one the wallet then declined to spend is worse: + * it is a well-formed id for an asset that would never exist. + */ +function resolveIssuances( + action: NormalisedAction, + context: { + fundingUtxos: SelectableUtxo[]; + /** What the wallet established about each input, which the issued asset joins. */ + inputs: Record<string, Record<string, unknown>>; + notes: NormalisationNote[]; + scope: ReferenceScope; + spent: Map<string, { txid: string; vout: number }>; + }, +): ResolvedIssuances { + const issuances: PlannedIssuance[] = []; + const reserved: SelectableUtxo[] = []; + + // Smallest first, and it is a choice about what is left rather than about this input: an + // issuance needs an output's identity and not its value, so taking the smallest leaves the + // most behind to fund the action with. Nothing here honours an amount the input declares + // for itself — no wallet input's amount is honoured today — which is recorded rather than + // hidden, because a protocol whose issuing input is also its collateral gets an output + // chosen for the wrong reason. + const spare = context.fundingUtxos + .filter((utxo) => utxo.spendable && !utxo.confidential) + .toSorted((one, other) => (toSats(one.amount) > toSats(other.amount) ? 1 : -1)); + + for (const entry of asArray(action.node.inputs)) { + const declared = asRecord(entry); + const issuance = declared && declaredIssuance(declared); + + if (!declared || !issuance) { + continue; + } + + const id = typeof declared.id === "string" ? declared.id : "(unnamed)"; + const onChain = context.spent.get(id); + const funding = onChain ? undefined : spare[reserved.length]; + const outpoint = onChain ?? (funding && { txid: funding.txid, vout: funding.vout }); + + if (!outpoint) { + return { + ok: false, + reason: + `Input ${id} issues an asset, which needs one of this wallet's own outputs to ` + + "derive it from, and there is none left to use.", + reject: "shortfall", + }; + } + + const resolved = resolveIssuance( + { declared: issuance, id, outpoint }, + context.scope, + context.notes, + ); + + if (!resolved.ok) { + return resolved; + } + + if (funding) { + reserved.push(funding); + } + + issuances.push(resolved.issuance); + context.inputs[id] = { + ...context.inputs[id], + ...issuanceAttributes(resolved.issuance), + }; + } + + return { issuances, ok: true, reserved }; +} + function findStateOutpoint( request: ParsedLiquidProcessCtParams, utxoType: string, From 3bcf55ac0af2e5b6964a456574ae618b5ec517eb Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 18:04:26 +0300 Subject: [PATCH 108/124] feat(tx-manifest): produce the stated witness value that selects a spend's branch The type and the literal travel to the compiler as text, because the compiler is what parses SimplicityHL and a wallet reading the literal for itself would be a second opinion about which branch of a contract runs. Names inside a value are resolved after the hooks; the language's own words are left exactly as written. Two of the four protocols that state a witness now have actions this wallet refuses nothing about, where before it refused all of them. --- .../processConfidentialTransaction/index.ts | 32 +++- packages/tx-manifest/src/corpus.test.ts | 9 +- packages/tx-manifest/src/currency.test.ts | 24 +-- .../tx-manifest/src/document/references.ts | 15 +- packages/tx-manifest/src/document/refuse.ts | 21 +++ .../tx-manifest/src/document/registry.test.ts | 10 +- packages/tx-manifest/src/document/registry.ts | 7 +- .../src/evaluation/witness.test.ts | 170 ++++++++++++++++++ .../tx-manifest/src/evaluation/witness.ts | 135 ++++++++++++++ packages/tx-manifest/src/review/index.test.ts | 60 +++++++ packages/tx-manifest/src/review/index.ts | 25 +++ 11 files changed, 468 insertions(+), 40 deletions(-) create mode 100644 packages/tx-manifest/src/evaluation/witness.test.ts create mode 100644 packages/tx-manifest/src/evaluation/witness.ts diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 099964a..be80bad 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -82,6 +82,28 @@ export type LiquidProcessCtDependencies = { withMnemonic: typeof withAccountMnemonic; }; +/** + * The witness values one covenant input needs, in the shape the signing module takes. + * + * A type and a literal, both text, keyed by the name the contract declares. The wallet does + * not parse either: the compiler that will type-check the literal is the authority on what it + * means, and a wallet reading `Right(Left(()))` for itself would be a second opinion about + * which branch of a contract runs. + */ +function witnessValuesJson( + values: { name: string; simplicityType: string; value: string }[] | undefined, +): string | undefined { + if (!values || values.length === 0) { + return undefined; + } + + return JSON.stringify( + Object.fromEntries( + values.map(({ name, simplicityType, value }) => [name, { type: simplicityType, value }]), + ), + ); +} + /** How the method is wired in the extension. Tests substitute what they need. */ export const liquidProcessCtDependencies: LiquidProcessCtDependencies = { // Imported when a transaction is actually broadcast rather than at module load: the @@ -173,10 +195,12 @@ export const createProcessLiquidConfidentialTransaction = ( covenant.txOutHex, covenant.source, covenant.argumentsJson, - // No witness values: a covenant that authenticates its spender needs a - // signature over this transaction, which only the signer can make, and - // naming it is what asks for one. - undefined, + // The values the document states outright, which is how a covenant with + // more than one branch is told which to run. A signature is not among + // them: only the signer can make one, and naming it below is what asks + // for one. Passed as the compiler's own witness shape — a type and a + // literal, both text — because the compiler is what parses SimplicityHL. + witnessValuesJson(covenant.witnessValues), covenant.signatureWitness, sequenceFor(review, covenant.id), ); diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index 92caf8a..59b785c 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -231,13 +231,6 @@ describe("what the corpus uses and this wallet does not read", () => { } } - expect([...unread].toSorted()).toEqual([ - "confidential", - "default", - "required_index", - "simplicity_type", - "source", - "value", - ]); + expect([...unread].toSorted()).toEqual(["confidential", "default", "required_index", "source"]); }); }); diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts index c3009c6..a727880 100644 --- a/packages/tx-manifest/src/currency.test.ts +++ b/packages/tx-manifest/src/currency.test.ts @@ -95,24 +95,10 @@ describe("the protocols as their authors publish them now", () => { ); expect(blocked).toEqual({ - dex: ["confidential", "default", "required_index", "simplicity_type", "value"], - last_will: ["default", "required_index", "simplicity_type", "value"], - lending_v2: [ - "compute", - "confidential", - "default", - "required_index", - "simplicity_type", - "value", - ], - lending_v3: [ - "compute", - "confidential", - "default", - "required_index", - "simplicity_type", - "value", - ], + dex: ["confidential", "default", "required_index"], + last_will: ["default", "required_index"], + lending_v2: ["compute", "confidential", "default", "required_index"], + lending_v3: ["compute", "confidential", "default", "required_index"], zeroconf: [], }); }); @@ -143,7 +129,7 @@ describe("the protocols as their authors publish them now", () => { expect(counted).toEqual({ dex: { found: 4, unblocked: 1 }, - last_will: { found: 4, unblocked: 0 }, + last_will: { found: 4, unblocked: 2 }, lending_v2: { found: 9, unblocked: 1 }, lending_v3: { found: 6, unblocked: 0 }, zeroconf: { found: 0, unblocked: 0 }, diff --git a/packages/tx-manifest/src/document/references.ts b/packages/tx-manifest/src/document/references.ts index 75e6632..cfbf2d1 100644 --- a/packages/tx-manifest/src/document/references.ts +++ b/packages/tx-manifest/src/document/references.ts @@ -73,7 +73,8 @@ export type ReferenceSiteKind = | "destination" | "expression" | "issuedAmount" - | "witnessKey"; + | "witnessKey" + | "witnessValue"; const SITES: Record<ReferenceSiteKind, { accepts: ReferenceForm[]; describes: string }> = { /** An output's amount, or an input's minimum. */ @@ -110,6 +111,18 @@ const SITES: Record<ReferenceSiteKind, { accepts: ReferenceForm[]; describes: st accepts: ["instance", "params", "args", "bare"], describes: "a witness key", }, + /** + * A name appearing inside the typed value a witness states. + * + * The fee is absent because a witness decides which branch of a contract runs, and a + * branch chosen by what the transaction costs would change as the transaction's own shape + * changed. An attribute of a resolved input is absent because no published protocol reads + * one here and admitting a form nothing exercises is admitting one nothing checks. + */ + witnessValue: { + accepts: ["instance", "params", "args"], + describes: "part of a witness value", + }, }; /** The namespaces a prefixed reference can name, and what each canonically resolves as. */ diff --git a/packages/tx-manifest/src/document/refuse.ts b/packages/tx-manifest/src/document/refuse.ts index ed76f37..848c945 100644 --- a/packages/tx-manifest/src/document/refuse.ts +++ b/packages/tx-manifest/src/document/refuse.ts @@ -1,3 +1,4 @@ +import { STATIC_WITNESS } from "../evaluation/witness"; import { asArray, asRecord } from "./json"; import type { NormalisedManifest } from "./normalise"; import { loadBearing, inspectConstructs } from "./registry"; @@ -316,6 +317,26 @@ function refuseUnproducibleWitness(manifest: NormalisedManifest): Refusal | unde const witness = asRecord(entry); const at = `${action.name} / input ${id} / witness ${name}`; + // A value the document states outright is produced rather than signed: the type + // and the literal travel to the compiler as text, which is the component that + // parses SimplicityHL. Both have to be there — a stated value missing its type + // is a witness nothing can check. + if (witness?.type === STATIC_WITNESS) { + if (typeof witness.simplicity_type === "string" && typeof witness.value === "string") { + continue; + } + + return { + reason: + `The witness ${name} at ${at} states a value and ` + + (typeof witness.simplicity_type === "string" + ? "no value to give it." + : "no type to give it.") + + " This wallet will not hand a contract a value nothing can type-check.", + reject: "unproducible-witness", + }; + } + if (witness?.type !== "Signature") { return { reason: diff --git a/packages/tx-manifest/src/document/registry.test.ts b/packages/tx-manifest/src/document/registry.test.ts index 667da20..e2f2e30 100644 --- a/packages/tx-manifest/src/document/registry.test.ts +++ b/packages/tx-manifest/src/document/registry.test.ts @@ -104,8 +104,8 @@ describe("inspectConstructs", () => { }); describe("the sites it reaches", () => { - // `simplicity_type` rather than `sig_type`: the runtime reads the witness's type, - // source and sighash type now, and a construct it reads is not a finding. + // Every key the witness site lists is read now, so an unrecognised one is what is left + // to find there — and finding it proves the walk reaches inside a witness at all. test("a witness on an input", () => { const findings = inspect({ actions: { @@ -114,16 +114,14 @@ describe("inspectConstructs", () => { { id: "p2pk_in", utxo_source: { utxo_type: "v" }, - witnesses: { SIGNATURE: { simplicity_type: "u256", type: "Signature" } }, + witnesses: { SIGNATURE: { salt: "0x00", type: "Signature" } }, }, ], }, }, }); - expect(at(findings, "simplicity_type")?.at).toBe( - "action Receive / input p2pk_in / witness SIGNATURE", - ); + expect(at(findings, "salt")?.at).toBe("action Receive / input p2pk_in / witness SIGNATURE"); }); // The script site's own keys are all read now, so an unrecognised one is what is left diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index f622bdf..fc46829 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -338,10 +338,13 @@ const SITES = { constructs: { description: SHOWN, sig_type: READ, - simplicity_type: UNIMPLEMENTED, + // The type and the literal of a value the document states outright. They are read + // together and never apart: a value with no type is a witness nothing can + // type-check, and a type with no value names nothing to check. + simplicity_type: READ, source: READ, type: READ, - value: UNIMPLEMENTED, + value: READ, }, unknownIsLoadBearing: true, }, diff --git a/packages/tx-manifest/src/evaluation/witness.test.ts b/packages/tx-manifest/src/evaluation/witness.test.ts new file mode 100644 index 0000000..a7f64db --- /dev/null +++ b/packages/tx-manifest/src/evaluation/witness.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "bun:test"; + +import dex from "../__fixtures__/current/dex.manifest.json"; +import lastWill from "../__fixtures__/current/last_will.manifest.json"; +import lendingV2 from "../__fixtures__/current/lending_v2.manifest.json"; +import lendingV3 from "../__fixtures__/current/lending_v3.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import { resolveStaticWitnesses } from "./witness"; + +const CORPUS = { + dex, + last_will: lastWill, + lending_v2: lendingV2, + lending_v3: lendingV3, +} as unknown as Record<string, Record<string, unknown>>; + +function actionsOf(document: Record<string, unknown>) { + return normaliseManifest(document).manifest.actions; +} + +const EMPTY: ReferenceScope = { instance: {}, params: {} }; + +/** The one field a published witness value reads, with a figure that is unmistakable in output. */ +const DEPLOYED: ReferenceScope = { instance: { CURRENT_DEBT: "4200" }, params: {} }; + +/** One covenant input carrying one witness, for the shapes no published protocol writes. */ +function witnessAction(witness: Record<string, unknown>) { + const { manifest } = normaliseManifest({ + actions: { + Spend: { + inputs: [ + { id: "covenant_in", utxo_source: { utxo_type: "v" }, witnesses: { PATH: witness } }, + ], + }, + }, + }); + + return manifest.actions[0]!; +} + +describe("the values published protocols state for their witnesses", () => { + // Every branch selector in the corpus, read at once. A protocol whose witness cannot be + // produced cannot be performed at all, so the number that resolve is the number of spends + // this wallet could reach. + test("all of them resolve, in every protocol that states one", () => { + const stated: Record<string, number> = {}; + + for (const name of Object.keys(CORPUS)) { + let count = 0; + + for (const action of actionsOf(CORPUS[name]!)) { + const resolved = resolveStaticWitnesses(action, DEPLOYED, []); + + expect({ [`${name} / ${action.name}`]: resolved.ok }).toEqual({ + [`${name} / ${action.name}`]: true, + }); + + if (resolved.ok) { + for (const values of resolved.witnesses.values()) { + count += values.length; + } + } + } + + stated[name] = count; + } + + // Counted from the documents themselves: every witness the four protocols declare, less + // the five that are signatures. Thirty-one stated values, once per action that spends. + expect(stated).toEqual({ dex: 2, last_will: 3, lending_v2: 18, lending_v3: 8 }); + }); + + // The language's own words are not references and must survive untouched. A runtime that + // substituted `Left` would choose a different branch of the contract than the document did. + test("a literal is carried through exactly as the document wrote it", () => { + const action = findAction(normaliseManifest(CORPUS.last_will!).manifest, "Inherit"); + const resolved = action && resolveStaticWitnesses(action, EMPTY, []); + + expect(resolved?.ok).toBe(true); + + if (!resolved?.ok) { + return; + } + + const [stated] = [...resolved.witnesses.values()].flat(); + + expect(stated?.value).toBe("Left(())"); + expect(stated?.simplicityType).toBe("Either<(), Either<(), ()>>"); + }); +}); + +describe("the one value in the corpus that is not a literal", () => { + /** The action whose branch carries a field of its own deployment. */ + function repaying() { + for (const action of actionsOf(CORPUS.lending_v3!)) { + const resolved = resolveStaticWitnesses(action, DEPLOYED, []); + + if (resolved.ok) { + for (const values of resolved.witnesses.values()) { + for (const stated of values) { + if (stated.value.includes("4200")) { + return stated; + } + } + } + } + } + + return undefined; + } + + test("reads the deployment field named inside it and leaves the rest alone", () => { + expect(repaying()?.value).toBe("Right(Left(Right(4200)))"); + }); + + // A branch selected from a field nobody supplied is the one case where carrying on would + // hand the contract a different branch than the document chose, so it refuses instead. + test("refuses when the field it names was not supplied", () => { + const failures = actionsOf(CORPUS.lending_v3!) + .map((action) => resolveStaticWitnesses(action, EMPTY, [])) + .filter((resolved) => !resolved.ok); + + expect(failures.length).toBe(1); + expect(failures[0]?.ok === false && failures[0].reason).toContain("CURRENT_DEBT"); + }); +}); + +describe("what a stated witness cannot be", () => { + test("a value with no type is refused, naming the input", () => { + const resolved = resolveStaticWitnesses( + witnessAction({ type: "simplicityhl", value: "Left(())" }), + EMPTY, + [], + ); + + expect(resolved.ok).toBe(false); + + if (!resolved.ok) { + expect(resolved.reason).toContain("covenant_in"); + } + }); + + test("a type with no value is refused", () => { + expect( + resolveStaticWitnesses( + witnessAction({ simplicity_type: "u32", type: "simplicityhl" }), + EMPTY, + [], + ).ok, + ).toBe(false); + }); + + // An attribute of a resolved input is not a form this site accepts. Left in place it would + // reach the compiler as a word it cannot parse, and resolved it would admit a lookup no + // published protocol exercises. + test("a name this position does not accept is refused rather than passed on", () => { + const resolved = resolveStaticWitnesses( + witnessAction({ + simplicity_type: "u32", + type: "simplicityhl", + value: "Left(covenant_in.amount_sat)", + }), + EMPTY, + [], + ); + + expect(resolved.ok).toBe(false); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/witness.ts b/packages/tx-manifest/src/evaluation/witness.ts new file mode 100644 index 0000000..c2a925a --- /dev/null +++ b/packages/tx-manifest/src/evaluation/witness.ts @@ -0,0 +1,135 @@ +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import { type ReferenceScope, parseReference, resolveReference } from "../document/references"; + +/** + * One witness value the document states outright, ready for the module that type-checks it. + * + * The type and the value stay text. A SimplicityHL literal is the compiler's to parse, and + * this wallet does not implement that language — reading `Right(Left(()))` as a structure here + * would be a second opinion about which branch a contract runs, given by the one component + * with no way to check it. + */ +export type StaticWitness = { + /** The witness the contract declares, by the name it declares it under. */ + name: string; + /** The SimplicityHL type the document states for it. */ + simplicityType: string; + /** The literal, with every name inside it replaced by what it refers to. */ + value: string; +}; + +export type StaticWitnessResult = + | { ok: false; reason: string } + | { ok: true; witnesses: Map<string, StaticWitness[]> }; + +/** The witness kind whose value the document states rather than computes. */ +export const STATIC_WITNESS = "simplicityhl"; + +/** + * Every static witness this action states, keyed by the input that carries it. + * + * Resolved after the hooks rather than beside the covenant, because one published protocol + * selects a branch by a field of its own deployment: the value is a literal with a name inside + * it, and the name is not known until everything that writes fields has run. + */ +export function resolveStaticWitnesses( + action: NormalisedAction, + scope: ReferenceScope, + notes?: NormalisationNote[], +): StaticWitnessResult { + const witnesses = new Map<string, StaticWitness[]>(); + + for (const entry of asArray(action.node.inputs)) { + const input = asRecord(entry); + + if (!input) { + continue; + } + + const id = typeof input.id === "string" ? input.id : "(unnamed)"; + const stated: StaticWitness[] = []; + + for (const [name, declared] of Object.entries(asRecord(input.witnesses) ?? {})) { + const witness = asRecord(declared); + + if (witness?.type !== STATIC_WITNESS) { + continue; + } + + const simplicityType = witness.simplicity_type; + const value = witness.value; + + if (typeof simplicityType !== "string" || typeof value !== "string") { + return { + ok: false, + reason: + `The witness ${name} on input ${id} is a stated value, and the document states ` + + "either no type for it or no value.", + }; + } + + const filled = fill(value, scope, notes); + + if (!filled.ok) { + return { ok: false, reason: `The witness ${name} on input ${id}: ${filled.reason}` }; + } + + stated.push({ name, simplicityType, value: filled.value }); + } + + if (stated.length > 0) { + witnesses.set(id, stated); + } + } + + return { ok: true, witnesses }; +} + +/** Names inside a literal, and nothing else: `instance.X`, `params.X`, `args.X`. */ +const NAMED = /\$?[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*/g; + +/** + * Replaces the names a stated value refers to, leaving the rest of the text exactly as written. + * + * Thirty of the corpus's thirty-one stated values are literals with nothing to replace. The + * thirty-first selects a branch carrying a field of its own deployment, so a runtime that + * skipped this would hand the compiler the word `instance.CURRENT_DEBT` and be told the + * literal does not parse — which is true and says nothing about why. + * + * Only a name with a namespace is touched. `Left`, `Right` and `u32` are the language's own + * words, and substituting one of those would rewrite the branch the document chose. + */ +function fill( + value: string, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: string } { + let failure: string | undefined; + + const filled = value.replaceAll(NAMED, (text) => { + const reference = parseReference(text); + + // A dotted name this runtime reads as an input and one of its attributes is not a + // reference here: the site accepts three namespaces and nothing else, so leaving it + // alone would hand the compiler a word it cannot parse and claiming it resolves would + // be worse. Refusing names it. + if (reference?.form === "input-attribute") { + failure ??= `"${text}" cannot be used as part of a witness value.`; + + return text; + } + + const found = resolveReference(text, "witnessValue", scope, notes); + + if (!found.ok) { + failure ??= found.reason; + + return text; + } + + return String(found.value); + }); + + return failure === undefined ? { ok: true, value: filled } : { ok: false, reason: failure }; +} diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts index 6270d27..a5aa34a 100644 --- a/packages/tx-manifest/src/review/index.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -689,3 +689,63 @@ describe("an input that creates an asset", () => { } }); }); + +describe("a covenant told which branch to run", () => { + /** + * The p2pk spend, with a branch selector grafted onto its covenant input. + * + * The declaration is the shape every published protocol writes: a SimplicityHL type and a + * literal, both stated by the document. p2pk's own contract has one branch, so the value + * here proves the path rather than the protocol. + */ + function branchingManifest() { + const document = structuredClone(p2pkManifest) as unknown as Record<string, unknown>; + const actions = document.actions as Record<string, Record<string, unknown>>; + const inputs = actions.Receive!.inputs as Record<string, unknown>[]; + const witnesses = inputs[0]!.witnesses as Record<string, unknown>; + + witnesses.PATH = { + simplicity_type: "Either<u32, u32>", + type: "simplicityhl", + value: "Left(instance.CHOSEN)", + }; + + return document; + } + + test("carries the stated value on the input it belongs to, resolved", async () => { + const result = await reviewManifestAction( + { + ...spendRequest(oneCovenantUtxo), + instance: { instance: { fields: { CHOSEN: "7" } } }, + manifest: branchingManifest(), + }, + { ...deps, readTxOut: readTxOut(DERIVED_SCRIPT) }, + ); + + expect(isRefusal(result)).toBe(false); + + if (isRefusal(result)) { + return; + } + + const spent = result.covenantInputs.find((covenant) => covenant.id === "p2pk_in"); + + // The type travels unparsed and the literal has its one name filled in. Nothing else in + // the text is touched: `Left` is the language's word, not a value to look up. + expect(spent?.witnessValues).toEqual([ + { name: "PATH", simplicityType: "Either<u32, u32>", value: "Left(7)" }, + ]); + // The signature the covenant needs is still asked for beside it, not replaced by it. + expect(spent?.signatureWitness).toBe("SIGNATURE"); + }); + + test("refuses when the value names something the deployment does not carry", async () => { + const result = await reviewManifestAction( + { ...spendRequest(oneCovenantUtxo), manifest: branchingManifest() }, + { ...deps, readTxOut: readTxOut(DERIVED_SCRIPT) }, + ); + + expect(isRefusal(result)).toBe(true); + }); +}); diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index b4edecc..f86bf2e 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -36,6 +36,7 @@ import { } from "../evaluation/issuance"; import { planAction } from "../evaluation/plan"; import { checkValidations } from "../evaluation/validate"; +import { type StaticWitness, resolveStaticWitnesses } from "../evaluation/witness"; import { estimateFeeSats } from "../fee"; import type { ParsedLiquidProcessCtParams } from "../request/request"; import { resolveActionRequirements } from "../request/requirements"; @@ -80,6 +81,14 @@ export type ReviewedCovenantInput = { txOutHex: string; txid: string; vout: number; + /** + * The values this input's contract needs supplied rather than signed. + * + * A covenant with more than one branch is told which one to run by a witness the document + * states outright. Carried through unparsed, because the compiler that type-checks a + * SimplicityHL literal is the authority on what it means and this package is not. + */ + witnessValues?: StaticWitness[]; }; /** One output of the transaction the wallet worked out, ready to be shown and then built. */ @@ -421,6 +430,22 @@ export async function reviewManifestAction( scope = hooked.scope; + // Stated witness values come after the hooks, because one published protocol selects its + // branch by a field of its own deployment and a hook is what may have written it. + const stated = resolveStaticWitnesses(action, scope, notes); + + if (!stated.ok) { + return { reason: stated.reason, refused: true, reject: "document-fault" }; + } + + for (const covenant of covenantInputs) { + const values = stated.witnesses.get(covenant.id); + + if (values) { + covenant.witnessValues = values; + } + } + let feeRateSatsPerKvb: number; try { From f13418462fbfd9df7b7d9ba0cba8faf4697c2ed3 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 18:10:45 +0300 Subject: [PATCH 109/124] feat(tx-manifest): resolve every output's blinding by the order the format defines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The output's own word, then the document's, then the network's — which on Liquid means an output nobody spoke about is hidden. A covenant output and an OP_RETURN are open before the order is consulted at all. Nothing acts on the answer yet, deliberately. Thirty-eight of the corpus's outputs resolve to hidden purely because nobody spoke, and this wallet builds explicit values, so refusing them takes the wallet from performing some protocols to performing almost none. That is the maintainer's decision and it is recorded as one. --- packages/tx-manifest/src/document/registry.ts | 2 +- .../src/evaluation/blinding.test.ts | 109 ++++++++++++++++++ .../tx-manifest/src/evaluation/blinding.ts | 70 +++++++++++ .../tx-manifest/src/evaluation/plan.test.ts | 6 + packages/tx-manifest/src/evaluation/plan.ts | 21 +++- packages/tx-manifest/src/review/index.ts | 19 ++- 6 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 packages/tx-manifest/src/evaluation/blinding.test.ts create mode 100644 packages/tx-manifest/src/evaluation/blinding.ts diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index fc46829..caa6d9e 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -229,11 +229,11 @@ const SITES = { chain: READ, classes: READ, compile_debug_symbols: READ, + confidential_outputs: UNIMPLEMENTED, // The container of a contract's actions, under the name the corpus uses now. Its // previous name is `classes` above; the normaliser reads both and neither is // preferred, because several generations of the same protocol coexist. contract_templates: READ, - confidential_outputs: UNIMPLEMENTED, description: SHOWN, errors: SHOWN, lifecycle: SHOWN, diff --git a/packages/tx-manifest/src/evaluation/blinding.test.ts b/packages/tx-manifest/src/evaluation/blinding.test.ts new file mode 100644 index 0000000..bd224c2 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/blinding.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; + +import dex from "../__fixtures__/current/dex.manifest.json"; +import lastWill from "../__fixtures__/current/last_will.manifest.json"; +import lendingV2 from "../__fixtures__/current/lending_v2.manifest.json"; +import lendingV3 from "../__fixtures__/current/lending_v3.manifest.json"; +import { asArray, asRecord } from "../document/json"; +import { normaliseManifest } from "../document/normalise"; +import { resolveBlinding } from "./blinding"; + +const CORPUS = { + dex, + last_will: lastWill, + lending_v2: lendingV2, + lending_v3: lendingV3, +} as unknown as Record<string, Record<string, unknown>>; + +describe("the order the format resolves blinding in", () => { + test("the output's own word comes first, over the document's", () => { + expect(resolveBlinding({ declared: false, documentDefault: true })).toEqual({ + blinding: "open", + decidedBy: "output", + }); + expect(resolveBlinding({ declared: true, documentDefault: false })).toEqual({ + blinding: "hidden", + decidedBy: "output", + }); + }); + + test("the document's word comes next, when the output says nothing", () => { + expect(resolveBlinding({ documentDefault: false })).toEqual({ + blinding: "open", + decidedBy: "document", + }); + }); + + // The step that makes silence a decision. On Liquid an output nobody spoke about is hidden, + // so a runtime that read the first two steps and stopped would build the opposite. + test("and silence means hidden, because that is this network's own default", () => { + expect(resolveBlinding({})).toEqual({ blinding: "hidden", decidedBy: "chain" }); + }); + + // Before the precedence is consulted at all: a Simplicity program reads exact amounts + // through jets that cannot introspect a commitment, and an OP_RETURN carries no value. + test("a covenant output and an OP_RETURN are open whatever anything says", () => { + expect(resolveBlinding({ declared: true, unblindable: "covenant" })).toEqual({ + blinding: "open", + decidedBy: "unblindable", + }); + expect(resolveBlinding({ documentDefault: true, unblindable: "data" })).toEqual({ + blinding: "open", + decidedBy: "unblindable", + }); + }); +}); + +describe("what the published protocols resolve to", () => { + /** Every output the corpus declares, with the destination that decides whether it can hide. */ + function outputsOf(document: Record<string, unknown>) { + const { manifest } = normaliseManifest(document); + const found: { declared: unknown; unblindable?: "covenant" | "data" }[] = []; + + for (const action of manifest.actions) { + for (const entry of asArray(action.node.outputs)) { + const output = asRecord(entry); + const destination = asRecord(output?.destination); + + found.push({ + declared: output?.confidential, + ...(typeof destination?.utxo_type === "string" + ? { unblindable: "covenant" as const } + : destination?.type === "op_return" + ? { unblindable: "data" as const } + : {}), + }); + } + } + + return found; + } + + // The count this issue turns on. Every output the four protocols declare, by what decided + // it — and the number decided by the network's own default is the number this wallet + // cannot build the moment anything acts on the answer. + test("counted across every output of every protocol", () => { + const counted: Record<string, number> = {}; + + for (const name of Object.keys(CORPUS)) { + for (const output of outputsOf(CORPUS[name]!)) { + const { blinding, decidedBy } = resolveBlinding({ + ...output, + documentDefault: CORPUS[name]!.confidential_outputs, + }); + + counted[`${blinding}/${decidedBy}`] = (counted[`${blinding}/${decidedBy}`] ?? 0) + 1; + } + } + + // Nothing is ever decided by the document: no published manifest states a file-level + // default. Thirty-nine outputs are open because their destination could never hide and + // twenty because the protocol said so — while thirty-eight are hidden purely because + // nobody spoke, and every one of those is an output this wallet cannot build. + expect(counted).toEqual({ + "hidden/chain": 38, + "open/output": 20, + "open/unblindable": 39, + }); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/blinding.ts b/packages/tx-manifest/src/evaluation/blinding.ts new file mode 100644 index 0000000..4190645 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/blinding.ts @@ -0,0 +1,70 @@ +/** + * Whether one output hides what it carries. + * + * The format states the order and it is short: the output's own word, then the document's + * file-level word, then the chain's. On Liquid the chain's word is that an output is hidden, + * which makes silence a decision rather than an absence — and a runtime that read the first + * two and stopped would build an open output for every document that says nothing, which is + * every document in the published corpus. + */ + +/** What an output does with the value it carries. */ +export type Blinding = "hidden" | "open"; + +/** + * Where an output's blinding was decided, so a refusal can say whose word it was. + * + * The word matters more than the answer: "this protocol asked for it" and "nobody said, and + * the network's own default is to hide" are the same outcome and different sentences, and a + * person deciding whether to trust a site is owed the difference. + */ +export type BlindingDecision = { + blinding: Blinding; + decidedBy: "chain" | "document" | "output" | "unblindable"; +}; + +/** A destination that can never hide what it carries, whatever anything says. */ +export type UnblindableTarget = "covenant" | "data"; + +/** + * Resolves one output's blinding by the precedence the format defines. + * + * A covenant output and an OP_RETURN are answered before the precedence is consulted at all. + * A Simplicity program reads exact amounts and asset ids through jets that cannot introspect a + * commitment, so a hidden covenant output is one its own contract could never check; an + * OP_RETURN carries bytes rather than value and has nothing to hide. + */ +export function resolveBlinding(input: { + /** The document's file-level default, when it states one. */ + documentDefault?: unknown; + /** The output's own declaration, when it states one. */ + declared?: unknown; + /** Set when the destination cannot hide anything whatever the document says. */ + unblindable?: UnblindableTarget; +}): BlindingDecision { + if (input.unblindable) { + return { blinding: "open", decidedBy: "unblindable" }; + } + + if (typeof input.declared === "boolean") { + return { blinding: input.declared ? "hidden" : "open", decidedBy: "output" }; + } + + if (typeof input.documentDefault === "boolean") { + return { blinding: input.documentDefault ? "hidden" : "open", decidedBy: "document" }; + } + + // Liquid hides by default. The format also defines a Bitcoin default of open, and this + // runtime builds Liquid transactions and refuses every other chain before reaching here, + // so there is no second branch to write rather than a branch left unwritten. + return { blinding: "hidden", decidedBy: "chain" }; +} + +/** How a refusal says whose word made this output one the wallet cannot build. */ +export function describeBlinding(decision: BlindingDecision): string { + return decision.decidedBy === "output" + ? "this protocol asks for it to be hidden" + : decision.decidedBy === "document" + ? "this protocol hides its outputs by default" + : "nothing says otherwise and this network hides an output by default"; +} diff --git a/packages/tx-manifest/src/evaluation/plan.test.ts b/packages/tx-manifest/src/evaluation/plan.test.ts index 719b64d..dc94779 100644 --- a/packages/tx-manifest/src/evaluation/plan.test.ts +++ b/packages/tx-manifest/src/evaluation/plan.test.ts @@ -29,6 +29,9 @@ describe("planAction", () => { if (result.ok) { expect(result.plan.fundingSats).toBe(50_000n); expect(result.plan.outputs).toContainEqual({ + // A covenant output can never hide what it carries: its own program reads the + // amount, and no jet can introspect a commitment. + blinding: { blinding: "open", decidedBy: "unblindable" }, id: "p2pk_out", sats: 50_000n, target: { kind: "covenant", utxoType: "p2pk_output" }, @@ -113,6 +116,9 @@ describe("planAction with resolved inputs", () => { if (result.ok) { expect(result.plan.outputs).toContainEqual({ + // An output paid to the wallet says nothing about hiding, and on this network + // silence means hidden. Nothing acts on that yet; the plan carries it. + blinding: { blinding: "hidden", decidedBy: "chain" }, id: "received_out", sats: 42_000n, target: { kind: "wallet" }, diff --git a/packages/tx-manifest/src/evaluation/plan.ts b/packages/tx-manifest/src/evaluation/plan.ts index 5c6eb1e..aee4432 100644 --- a/packages/tx-manifest/src/evaluation/plan.ts +++ b/packages/tx-manifest/src/evaluation/plan.ts @@ -1,6 +1,7 @@ import { asArray, asRecord } from "../document/json"; import type { NormalisationNote, NormalisedAction } from "../document/normalise"; import { type ReferenceScope, resolveReference } from "../document/references"; +import { type BlindingDecision, resolveBlinding } from "./blinding"; import { encodeDataParts } from "./encode"; import { evaluateExpression } from "./evaluate"; @@ -11,6 +12,8 @@ import { evaluateExpression } from "./evaluate"; * 2^53 is representable in a transaction and not in a double. */ export type PlannedOutput = { + /** Whether this output hides what it carries, and whose word decided that. */ + blinding: BlindingDecision; /** The manifest's id for this output, for anything that has to name it. */ id: string; /** Absent for change, whose amount is whatever is left after the fee. */ @@ -44,6 +47,8 @@ export function planAction( action: NormalisedAction, scope: ReferenceScope, notes?: NormalisationNote[], + /** The document's file-level blinding default, which no published manifest states. */ + documentDefault?: unknown, ): PlanResult { const outputs: PlannedOutput[] = []; let fundingSats = 0n; @@ -62,8 +67,18 @@ export function planAction( return { ok: false, reason: `Output ${id || "(unnamed)"} ${target.reason}` }; } + const blinding = resolveBlinding({ + declared: output.confidential, + documentDefault, + ...(target.target.kind === "covenant" + ? { unblindable: "covenant" as const } + : target.target.kind === "data" + ? { unblindable: "data" as const } + : {}), + }); + if (target.target.kind === "change") { - outputs.push({ id, target: target.target }); + outputs.push({ blinding, id, target: target.target }); continue; } @@ -71,7 +86,7 @@ export function planAction( // An op_return output carries bytes rather than value. It is provably unspendable, so // it pays nothing and nothing needs to fund it. if (target.target.kind === "data") { - outputs.push({ id, sats: 0n, target: target.target }); + outputs.push({ blinding, id, sats: 0n, target: target.target }); continue; } @@ -93,7 +108,7 @@ export function planAction( } fundingSats += amount.sats; - outputs.push({ id, sats: amount.sats, target: target.target }); + outputs.push({ blinding, id, sats: amount.sats, target: target.target }); } if (outputs.length === 0) { diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index f86bf2e..60beab3 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -466,7 +466,12 @@ export async function reviewManifestAction( // One pass is enough because an amount does not change what a transaction weighs: in // Elements a value occupies a fixed size whatever its magnitude. Without that property // this would not converge. - const draft = planAction(action, { ...scope, fee: 0n }, notes); + const draft = planAction( + action, + { ...scope, fee: 0n }, + notes, + manifest.node.confidential_outputs, + ); if (!draft.ok) { return { reason: draft.reason, refused: true, reject: "document-fault" }; @@ -484,7 +489,12 @@ export async function reviewManifestAction( feeRateSatsPerKvb, ); - const plan = planAction(action, { ...scope, fee: estimatedFee }, notes); + const plan = planAction( + action, + { ...scope, fee: estimatedFee }, + notes, + manifest.node.confidential_outputs, + ); if (!plan.ok) { return { reason: plan.reason, refused: true, reject: "document-fault" }; @@ -506,6 +516,11 @@ export async function reviewManifestAction( return { reason: failed.reason, refused: true, reject: "document-fault" }; } + // Nothing acts on what the precedence decided yet. Every output in the published corpus + // that says nothing resolves to hidden, and this wallet builds explicit values — so acting + // on it refuses almost every protocol, including two that pass today. That is a decision + // about what the product can do rather than one about this code, and it is with the + // maintainer. const covenantScripts = new Map( covenants.map((found) => [found.utxoType, found.scriptPubKeyHex]), ); From 261ce8bfcdd7bc9d47781094d3773bd735bd3bb8 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 13 Aug 2026 18:16:11 +0300 Subject: [PATCH 110/124] feat(tx-manifest): fill the parameters a protocol already knows the answer to What the request supplied wins, then a value the document computes from the deployment, then the literal it names as its default. Filling happens before the completeness check, because the other order reports a parameter as missing that the document itself supplies. A value taken from the wallet's own key or address refuses by name: the review opens no signing key, deliberately, because it runs before anyone has approved anything. Three of last_will's four actions now carry nothing this wallet refuses. Two constructs remain across the whole corpus. --- packages/tx-manifest/src/corpus.test.ts | 8 +- packages/tx-manifest/src/currency.test.ts | 10 +- .../tx-manifest/src/document/registry.test.ts | 6 +- packages/tx-manifest/src/document/registry.ts | 13 +- .../src/evaluation/parameters.test.ts | 119 ++++++++++++++++++ .../tx-manifest/src/evaluation/parameters.ts | 106 ++++++++++++++++ packages/tx-manifest/src/review/index.ts | 41 +++--- 7 files changed, 275 insertions(+), 28 deletions(-) create mode 100644 packages/tx-manifest/src/evaluation/parameters.test.ts create mode 100644 packages/tx-manifest/src/evaluation/parameters.ts diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index 59b785c..e8dcead 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -180,11 +180,11 @@ describe("what this wallet can do with each published protocol", () => { // Recorded as the measurement rather than as an expectation: these five refuse today, and // the first construct each refuses on is what a slice implementing it would remove. test.each([ - ["dex", "default"], - ["last_will", "default"], + ["dex", "confidential"], + ["last_will", "required_index"], ["lending", "confidential"], ["lending_v2", "confidential"], - ["lending_v3", "default"], + ["lending_v3", "confidential"], ])("%s refuses, on %s", (name, construct) => { expect(refusalFor(name)).toContain(construct); }); @@ -231,6 +231,6 @@ describe("what the corpus uses and this wallet does not read", () => { } } - expect([...unread].toSorted()).toEqual(["confidential", "default", "required_index", "source"]); + expect([...unread].toSorted()).toEqual(["confidential", "required_index"]); }); }); diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts index a727880..ae38e54 100644 --- a/packages/tx-manifest/src/currency.test.ts +++ b/packages/tx-manifest/src/currency.test.ts @@ -95,10 +95,10 @@ describe("the protocols as their authors publish them now", () => { ); expect(blocked).toEqual({ - dex: ["confidential", "default", "required_index"], - last_will: ["default", "required_index"], - lending_v2: ["compute", "confidential", "default", "required_index"], - lending_v3: ["compute", "confidential", "default", "required_index"], + dex: ["confidential", "required_index"], + last_will: ["required_index"], + lending_v2: ["confidential", "required_index"], + lending_v3: ["confidential", "required_index"], zeroconf: [], }); }); @@ -129,7 +129,7 @@ describe("the protocols as their authors publish them now", () => { expect(counted).toEqual({ dex: { found: 4, unblocked: 1 }, - last_will: { found: 4, unblocked: 2 }, + last_will: { found: 4, unblocked: 3 }, lending_v2: { found: 9, unblocked: 1 }, lending_v3: { found: 6, unblocked: 0 }, zeroconf: { found: 0, unblocked: 0 }, diff --git a/packages/tx-manifest/src/document/registry.test.ts b/packages/tx-manifest/src/document/registry.test.ts index e2f2e30..69312c0 100644 --- a/packages/tx-manifest/src/document/registry.test.ts +++ b/packages/tx-manifest/src/document/registry.test.ts @@ -134,12 +134,14 @@ describe("inspectConstructs", () => { expect(at(findings, "salt")?.at).toBe("utxo type vault / script"); }); + // `derived` rather than `default`: how a parameter is filled is read now, and the one + // spelling of it the corpus never uses is what is left to find at this position. test("a parameter definition", () => { const findings = inspect({ - actions: { Pay: { params: { owner: { default: "0x00", type: "pubkey" } } } }, + actions: { Pay: { params: { owner: { derived: "0x00", type: "pubkey" } } } }, }); - expect(at(findings, "default")?.at).toBe("action Pay / param owner"); + expect(at(findings, "derived")?.at).toBe("action Pay / param owner"); }); // The rule itself is read now; an unrecognised key beside it is what is left to find. diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index caa6d9e..afd67d7 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -274,14 +274,21 @@ const SITES = { }, param: { constructs: { - compute: UNIMPLEMENTED, - default: UNIMPLEMENTED, + // How a parameter is filled without asking anyone. An expression is evaluated and a + // value the wallet itself holds refuses by name, because a review that opens no + // signing key cannot produce one. + compute: READ, + // The literal used when nothing supplied a value, which is the last of the three + // steps and never overwrites one a person chose. + default: READ, derived: UNIMPLEMENTED, description: SHOWN, // The reference implementation's own comment calls it informational only for // display, so it does not decide a value and cannot change what is signed. formula: UNREAD, - source: UNIMPLEMENTED, + // The oldest generation's spelling of a value the wallet supplies. Read together + // with the newer one, so a refusal names the thing rather than the spelling. + source: READ, type: READ, }, unknownIsLoadBearing: true, diff --git a/packages/tx-manifest/src/evaluation/parameters.test.ts b/packages/tx-manifest/src/evaluation/parameters.test.ts new file mode 100644 index 0000000..0903408 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/parameters.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; + +import dex from "../__fixtures__/current/dex.manifest.json"; +import lendingV2 from "../__fixtures__/current/lending_v2.manifest.json"; +import lendingV3 from "../__fixtures__/current/lending_v3.manifest.json"; +import lending from "../__fixtures__/lending.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import { fillParameters } from "./parameters"; + +function actionOf(document: unknown, name: string) { + const action = findAction(normaliseManifest(document as Record<string, unknown>).manifest, name); + + if (!action) { + throw new Error(`No action named ${name}`); + } + + return action; +} + +/** The action whose parameters carry the corpus's two computed expressions. */ +function computingAction() { + const { manifest } = normaliseManifest(lendingV3 as unknown as Record<string, unknown>); + + for (const action of manifest.actions) { + const declared = action.node.params as Record<string, Record<string, unknown>> | undefined; + + if (declared && Object.values(declared).some((spec) => typeof spec?.compute === "string")) { + return action; + } + } + + throw new Error("The corpus carries no computed parameter"); +} + +describe("a parameter the protocol already knows the answer to", () => { + test("takes the literal the document names, when nothing supplied one", () => { + const filled = fillParameters(actionOf(dex, "MakeOffer"), {}, { instance: {}, params: {} }); + + expect(filled.ok).toBe(true); + + if (filled.ok) { + expect(filled.params.MAX_FEE).toBe("5000"); + } + }); + + // The order that matters. Both are valid values, so a runtime with the order reversed would + // pass every test of the filled value and quietly replace what a person chose. + test("keeps what the request supplied, over the default", () => { + const filled = fillParameters( + actionOf(dex, "MakeOffer"), + { MAX_FEE: 12 }, + { instance: {}, params: {} }, + ); + + expect(filled.ok && filled.params.MAX_FEE).toBe(12); + }); + + test("is computed from the deployment, where the document computes it", () => { + const action = computingAction(); + const filled = fillParameters( + action, + {}, + { + instance: { + CURRENT_DEBT: "11000", + PRINCIPAL_AMOUNT: "10000", + PRINCIPAL_INTEREST_RATE: "500", + }, + params: {}, + }, + [], + ); + + expect(filled.ok).toBe(true); + + if (!filled.ok) { + return; + } + + // The protocol's fee is 10000 * 500 / 10000 * 1000 / 10000, which truncates to 50, and + // the lender's vault takes what is left of the 11000 owed. + expect(filled.params.TOTAL_PROTOCOL_FEE).toBe("50"); + expect(filled.params.LENDER_VAULT_AMOUNT).toBe("10950"); + }); +}); + +describe("a parameter filled from the wallet itself", () => { + // Three in the current generation, one in the oldest, and the review opens no signing key — + // so this refuses by name rather than telling a site it forgot to send something the + // document never asked it for. + test("refuses by name under the current generation's spelling", () => { + const filled = fillParameters( + actionOf(lendingV2, "IssueUtilityNFTs"), + {}, + { instance: {}, params: {} }, + ); + + expect(filled.ok).toBe(false); + + if (!filled.ok) { + expect(filled.reason).toContain("BORROWER_PUB_KEY"); + expect(filled.reject).toBe("unimplemented-construct"); + } + }); + + test("and under the oldest generation's, which spells it differently", () => { + const filled = fillParameters( + actionOf(lending, "IssueUtilityNFTs"), + {}, + { instance: {}, params: {} }, + ); + + expect(filled.ok).toBe(false); + + if (!filled.ok) { + expect(filled.reason).toContain("BORROWER_PUB_KEY"); + } + }); +}); diff --git a/packages/tx-manifest/src/evaluation/parameters.ts b/packages/tx-manifest/src/evaluation/parameters.ts new file mode 100644 index 0000000..d2d1d6a --- /dev/null +++ b/packages/tx-manifest/src/evaluation/parameters.ts @@ -0,0 +1,106 @@ +import { asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import type { RejectToken } from "../document/refuse"; +import { evaluateExpression } from "./evaluate"; + +export type FilledParams = + | { ok: false; reason: string; reject: RejectToken } + | { ok: true; params: Record<string, unknown> }; + +/** + * Fills the parameters a protocol already knows the answer to. + * + * Three things can fill one and their order is the whole of the rule. What the request + * supplied wins, always: a value a person chose is not a default's to overwrite. Then a value + * the document computes from the deployment. Then the literal the document names as its + * default, which is what "nothing supplied one" resolves to. + * + * The format states none of that order. It is derivable from what each of the three is for, + * and writing it the other way round would silently replace a chosen value with a default — + * which no test of the filled value alone would catch, because both are valid values. + */ +export function fillParameters( + action: NormalisedAction, + supplied: Record<string, unknown>, + scope: ReferenceScope, + notes?: NormalisationNote[], +): FilledParams { + const declared = asRecord(action.node.params); + + if (!declared) { + return { ok: true, params: supplied }; + } + + const params: Record<string, unknown> = { ...supplied }; + + for (const [name, entry] of Object.entries(declared)) { + const spec = asRecord(entry); + + if (!spec || params[name] !== undefined) { + continue; + } + + const fromWallet = walletSource(spec); + + // A value the wallet itself supplies, which this runtime cannot reach where filling + // happens: the review runs before anyone has approved anything and deliberately opens no + // signing key. Refused by name rather than reported as a parameter the site forgot, + // because the document did not ask the site for it. + if (fromWallet) { + return { + ok: false, + reason: + `The parameter ${name} is filled from this wallet's own ${fromWallet}, and this ` + + "wallet cannot supply it while reviewing an action.", + reject: "unimplemented-construct", + }; + } + + const computed = spec.compute; + + if (typeof computed === "string") { + const evaluated = evaluateExpression(computed, "issuedAmount", scope, notes); + + if (!evaluated.ok) { + return { + ok: false, + reason: `The parameter ${name} is computed, and ${evaluated.reason}`, + reject: "document-fault", + }; + } + + params[name] = String(evaluated.value); + + continue; + } + + // A default is a literal the document states, never a reference: every one of the nine in + // the corpus is a number or a byte string. Resolving it as a reference would make a + // default that happens to look like a name mean something else entirely. + if (typeof spec.default === "string" || typeof spec.default === "number") { + params[name] = String(spec.default); + } + } + + return { ok: true, params }; +} + +/** + * What of the wallet's own a parameter asks for, under either generation's spelling. + * + * The current generation writes `compute: {type: "wallet", wallet: "key"}`; the oldest writes + * `source: {type: "wallet_key"}`. They are the same request and are answered together, so a + * refusal names the thing rather than the spelling. + */ +function walletSource(spec: Record<string, unknown>): string | undefined { + const computed = asRecord(spec.compute); + + if (computed?.type === "wallet") { + return typeof computed.wallet === "string" ? computed.wallet.replaceAll("_", " ") : "value"; + } + + const source = asRecord(spec.source); + + return source?.type === "wallet_key" ? "key" : undefined; +} diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index 60beab3..d27db02 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -34,6 +34,7 @@ import { type PlannedIssuance, resolveIssuance, } from "../evaluation/issuance"; +import { fillParameters } from "../evaluation/parameters"; import { planAction } from "../evaluation/plan"; import { checkValidations } from "../evaluation/validate"; import { type StaticWitness, resolveStaticWitnesses } from "../evaluation/witness"; @@ -217,7 +218,29 @@ export async function reviewManifestAction( return { reason: refusal.reason, refused: true, reject: refusal.reject }; } - const requirements = resolveActionRequirements(request, manifest); + const action = findAction(manifest, request.action); + + if (!action) { + return { + reason: `The manifest declares no action named "${request.action}".`, + refused: true, + reject: "no-such-action", + }; + } + + // What the protocol already knows the answer to is filled before anything asks whether the + // request is complete. The other order reports a parameter as missing that the document + // itself supplies, which sends a site looking for a value it was never meant to send. + const filled = fillParameters(action, request.params, { + instance: normaliseInstance(request.instance).instance.fields, + params: request.params, + }); + + if (!filled.ok) { + return { reason: filled.reason, refused: true, reject: filled.reject }; + } + + const requirements = resolveActionRequirements({ ...request, params: filled.params }, manifest); if (requirements.missing.length > 0) { const named = requirements.missing @@ -231,16 +254,6 @@ export async function reviewManifestAction( }; } - const action = findAction(manifest, request.action); - - if (!action) { - return { - reason: `The manifest declares no action named "${request.action}".`, - refused: true, - reject: "no-such-action", - }; - } - const declaredTypes = declaredParamTypes(action.node); const covenants: CovenantFinding[] = []; const covenantInputs: ReviewedCovenantInput[] = []; @@ -256,7 +269,7 @@ export async function reviewManifestAction( contractSources: request.contractSources, hashCovenant: covenantHashFrom(input.scriptPubKeyOf), notes, - scope: { instance: deployment.instance.fields, params: request.params }, + scope: { instance: deployment.instance.fields, params: filled.params }, }); if (!computed.ok) { @@ -274,7 +287,7 @@ export async function reviewManifestAction( notes, scope: { instance: deployment.instance.fields, - params: { ...request.params, ...computed.values }, + params: { ...filled.params, ...computed.values }, }, }) : undefined; @@ -288,7 +301,7 @@ export async function reviewManifestAction( instance: created ? { ...deployment.instance.fields, ...created.instance.fields } : deployment.instance.fields, - params: { ...request.params, ...computed.values }, + params: { ...filled.params, ...computed.values }, }; for (const site of covenantSites(action)) { From 262c8aa28d598095be0e397beb0d311496db9458 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 11:51:08 +0300 Subject: [PATCH 111/124] feat(tx-manifest): hide the outputs the document wants hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signing module already takes a blinding key on an output and on change, and the signer already hands out the wallet's own — so this is the wallet passing what it always could rather than a new capability. Which outputs are hidden was decided while reading the document, because the builder has never read it. An output that must be hidden and pays an address the document names refuses: the blinding key there belongs to whoever owns that address. One construct is now left across the whole published corpus, and three of the four protocols that declare actions have actions this wallet refuses nothing about. --- .../processConfidentialTransaction/index.ts | 16 +++++- packages/tx-manifest/src/corpus.test.ts | 12 ++--- packages/tx-manifest/src/currency.test.ts | 10 ++-- packages/tx-manifest/src/document/registry.ts | 9 +++- packages/tx-manifest/src/review/index.test.ts | 3 ++ packages/tx-manifest/src/review/index.ts | 54 ++++++++++++++++--- 6 files changed, 83 insertions(+), 21 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index be80bad..8e35e85 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -210,15 +210,27 @@ export const createProcessLiquidConfidentialTransaction = ( builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); } + // An output the document wants hidden is hidden with this wallet's own blinding + // key. Which outputs those are was decided while reading the document, not + // here: the builder has never read it, and an output built the wrong way is + // one whose amount is published when the protocol meant it kept. for (const output of review.outputs) { - builder.addOutput(output.scriptPubKeyHex, output.sats, account.rawPolicyAssetId); + builder.addOutput( + output.scriptPubKeyHex, + output.sats, + account.rawPolicyAssetId, + output.blinded ? signer.blindingPublicKey() : undefined, + ); } // Where change goes is a fact about this transaction, so it is set on the // builder rather than passed to the call that signs it. Unset, the module // returns change to the signer's own derived address, which this wallet does // watch today but only because the signing path is limited to one index. - builder.addChange(signer.scriptPubKeyHex()); + builder.addChange( + signer.scriptPubKeyHex(), + review.changeBlinded ? signer.blindingPublicKey() : undefined, + ); const result = signer.finalizeTransaction(builder, review.feeRateSatsPerKvb); const extracted = { diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index e8dcead..3e67557 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -180,17 +180,17 @@ describe("what this wallet can do with each published protocol", () => { // Recorded as the measurement rather than as an expectation: these five refuse today, and // the first construct each refuses on is what a slice implementing it would remove. test.each([ - ["dex", "confidential"], + ["dex", "required_index"], ["last_will", "required_index"], - ["lending", "confidential"], - ["lending_v2", "confidential"], - ["lending_v3", "confidential"], + ["lending", "required_index"], + ["lending_v2", "required_index"], + ["lending_v3", "required_index"], ])("%s refuses, on %s", (name, construct) => { expect(refusalFor(name)).toContain(construct); }); test("and every refusal names where it was, not just what it was", () => { - expect(refusalFor("lending")).toContain("action PrepareLender"); + expect(refusalFor("lending")).toContain("action SetupLending / input collateral_in"); }); }); @@ -231,6 +231,6 @@ describe("what the corpus uses and this wallet does not read", () => { } } - expect([...unread].toSorted()).toEqual(["confidential", "required_index"]); + expect([...unread].toSorted()).toEqual(["required_index"]); }); }); diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts index ae38e54..d56b86e 100644 --- a/packages/tx-manifest/src/currency.test.ts +++ b/packages/tx-manifest/src/currency.test.ts @@ -95,10 +95,10 @@ describe("the protocols as their authors publish them now", () => { ); expect(blocked).toEqual({ - dex: ["confidential", "required_index"], + dex: ["required_index"], last_will: ["required_index"], - lending_v2: ["confidential", "required_index"], - lending_v3: ["confidential", "required_index"], + lending_v2: ["required_index"], + lending_v3: ["required_index"], zeroconf: [], }); }); @@ -128,9 +128,9 @@ describe("the protocols as their authors publish them now", () => { ); expect(counted).toEqual({ - dex: { found: 4, unblocked: 1 }, + dex: { found: 4, unblocked: 3 }, last_will: { found: 4, unblocked: 3 }, - lending_v2: { found: 9, unblocked: 1 }, + lending_v2: { found: 9, unblocked: 3 }, lending_v3: { found: 6, unblocked: 0 }, zeroconf: { found: 0, unblocked: 0 }, }); diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index afd67d7..3c8d168 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -229,7 +229,9 @@ const SITES = { chain: READ, classes: READ, compile_debug_symbols: READ, - confidential_outputs: UNIMPLEMENTED, + // The document's own default for whether an output hides what it carries. No published + // manifest states one, so it is read for the case none of them is. + confidential_outputs: READ, // The container of a contract's actions, under the name the corpus uses now. Its // previous name is `classes` above; the normaliser reads both and neither is // preferred, because several generations of the same protocol coexist. @@ -261,7 +263,10 @@ const SITES = { amount_sat: READ, asset: READ, condition: UNIMPLEMENTED, - confidential: UNIMPLEMENTED, + // Whether this output hides what it carries. The wallet hides it with its own + // blinding key; one paid to an address the document names refuses, because the key + // there belongs to whoever owns the address. + confidential: READ, data: READ, description: SHOWN, destination: READ, diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts index a5aa34a..0ab1614 100644 --- a/packages/tx-manifest/src/review/index.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -210,6 +210,9 @@ describe("reviewManifestAction", () => { if (!isRefusal(result)) { expect(result.outputs).toContainEqual({ + // The document says nothing about hiding this one, and on this network silence + // means hidden. + blinded: true, id: "received_out", sats: 77_000n, scriptPubKeyHex: WALLET_SCRIPT, diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index d27db02..1f366a9 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -94,6 +94,14 @@ export type ReviewedCovenantInput = { /** One output of the transaction the wallet worked out, ready to be shown and then built. */ export type ReviewedOutput = { + /** + * Whether this output hides what it carries, decided by the order the format defines. + * + * Carried rather than left to the builder, because the decision is the document's and the + * builder has never read the document. An output built the wrong way here is one whose + * amount is published when the protocol meant it kept, and nothing later could tell. + */ + blinded: boolean; id: string; sats: bigint; scriptPubKeyHex: string; @@ -109,6 +117,14 @@ export type ReviewedOutput = { */ export type ManifestReview = { action: string; + /** + * Whether the change this transaction returns hides what it carries. + * + * Change is an output like any other in the document's eyes, and the corpus declares one + * for almost every action while saying nothing about it — so the network's own default + * decides, and on Liquid that means hidden. + */ + changeBlinded: boolean; covenants: CovenantFinding[]; /** The covenant outputs this action spends, ready to be added as inputs. */ covenantInputs: ReviewedCovenantInput[]; @@ -529,11 +545,31 @@ export async function reviewManifestAction( return { reason: failed.reason, refused: true, reject: "document-fault" }; } - // Nothing acts on what the precedence decided yet. Every output in the published corpus - // that says nothing resolves to hidden, and this wallet builds explicit values — so acting - // on it refuses almost every protocol, including two that pass today. That is a decision - // about what the product can do rather than one about this code, and it is with the - // maintainer. + // An output the document wants hidden is hidden with this wallet's own blinding key, which + // is the key of the address it pays to. That holds for its own outputs and for its change, + // and not for an address the document names — there the key belongs to whoever owns that + // address, and this wallet has no way to obtain it. + const foreign = plan.plan.outputs.find( + (planned) => + planned.blinding.blinding === "hidden" && + planned.target.kind !== "change" && + planned.target.kind !== "wallet", + ); + + if (foreign) { + return { + reason: + `The output ${foreign.id || "(unnamed)"} must hide what it carries and pays somewhere ` + + "this wallet holds no blinding key for.", + refused: true, + reject: "unimplemented-construct", + }; + } + + /** Whether the transaction's change hides what it carries, by the same order. */ + const changeBlinded = + plan.plan.outputs.find((planned) => planned.target.kind === "change")?.blinding.blinding === + "hidden"; const covenantScripts = new Map( covenants.map((found) => [found.utxoType, found.scriptPubKeyHex]), ); @@ -563,7 +599,12 @@ export async function reviewManifestAction( }; } - outputs.push({ id: planned.id, sats: planned.sats, scriptPubKeyHex }); + outputs.push({ + blinded: planned.blinding.blinding === "hidden", + id: planned.id, + sats: planned.sats, + scriptPubKeyHex, + }); } // An action pinning an input to one address restricts what the wallet may fund it from. @@ -623,6 +664,7 @@ export async function reviewManifestAction( covenants, estimatedFeeSats: estimatedFee, feeRateSatsPerKvb, + changeBlinded, ignoredConstructs: ignored(inspectConstructs(manifest)), issuances: issued.issuances, normalisation: notes, From 1fccf99d123883a83bfe0a616816b6b89031ea20 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 11:58:39 +0300 Subject: [PATCH 112/124] feat(tx-manifest): land an input or output where the document says it must be A covenant introspects positions, so a transaction built in another order is one the network rejects after it has been signed. Being stricter than the reference tool here is the maintainer's decision of 2026-08-14; a negative index counts from the end, which the two change outputs in the corpus use. No published protocol is refused for a construct any more. What is left refuses on the asset it moves or the witness it asks for. --- packages/tx-manifest/src/corpus.test.ts | 75 ++++++++++------ packages/tx-manifest/src/currency.test.ts | 16 ++-- .../tx-manifest/src/document/inspect.test.ts | 2 +- packages/tx-manifest/src/document/refuse.ts | 3 + packages/tx-manifest/src/document/registry.ts | 11 +-- .../src/evaluation/positions.test.ts | 86 +++++++++++++++++++ .../tx-manifest/src/evaluation/positions.ts | 64 ++++++++++++++ packages/tx-manifest/src/review/index.ts | 67 +++++++++++++++ 8 files changed, 283 insertions(+), 41 deletions(-) create mode 100644 packages/tx-manifest/src/evaluation/positions.test.ts create mode 100644 packages/tx-manifest/src/evaluation/positions.ts diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index 3e67557..f4b87dd 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -67,21 +67,24 @@ describe("every refusal a published manifest earns is named, not only described" }); } - test("and every one the corpus refuses today refuses on a construct, which is a fact about the corpus", () => { - const named = (Object.keys(CORPUS) as (keyof typeof CORPUS)[]).map(rejectionOf).filter(Boolean); - - // Five of the seven, and all five on the same branch: each uses a construct this wallet - // does not implement, and that check runs before any other could fire. So the vocabulary - // is not exercised beyond one token by the published corpus — which is worth asserting - // rather than hiding, because the day a manifest is refused for a different reason this - // line is what says so. - expect(named).toEqual([ - "unimplemented-construct", - "unimplemented-construct", - "unimplemented-construct", - "unimplemented-construct", - "unimplemented-construct", - ]); + // The measurement this bundle exists to move. Every published protocol used to be refused + // on a construct this wallet did not implement; none is now. What is left is a different + // statement about the wallet rather than about its reading: three protocols move an asset + // it does not move, and the oldest generation asks for a witness it cannot produce. + test("and no published manifest is refused for a construct any more", () => { + const named = Object.fromEntries( + (Object.keys(CORPUS) as (keyof typeof CORPUS)[]).map((name) => [name, rejectionOf(name)]), + ); + + expect(named).toEqual({ + dex: "foreign-asset", + last_will: undefined, + lending: "unproducible-witness", + lending_v2: "foreign-asset", + lending_v3: "foreign-asset", + p2pk: undefined, + zeroconf: undefined, + }); }); }); @@ -177,20 +180,36 @@ describe("what this wallet can do with each published protocol", () => { expect(refusalFor("zeroconf")).toBe(""); }); - // Recorded as the measurement rather than as an expectation: these five refuse today, and - // the first construct each refuses on is what a slice implementing it would remove. + // Recorded as the measurement rather than as an expectation: what stops each of the three + // still refused is now the asset it moves or the witness it asks for, and no longer a part + // of the document this wallet had not read. test.each([ - ["dex", "required_index"], - ["last_will", "required_index"], - ["lending", "required_index"], - ["lending_v2", "required_index"], - ["lending_v3", "required_index"], - ])("%s refuses, on %s", (name, construct) => { - expect(refusalFor(name)).toContain(construct); + ["dex", "moves"], + ["lending", "witness"], + ["lending_v2", "moves"], + ["lending_v3", "moves"], + ])("%s refuses, and not on a construct", (name, because) => { + expect(refusalFor(name)).toContain(because); }); - test("and every refusal names where it was, not just what it was", () => { - expect(refusalFor("lending")).toContain("action SetupLending / input collateral_in"); + test("last_will is read and built, where it was refused before this bundle", () => { + expect(refusalFor("last_will")).toBe(""); + }); + + // A refusal still names where it was. Nothing in the corpus earns one from its document + // alone any more, so this exercises the sentence on a document written to earn it. + test("and a refusal names where it was, not just what it was", () => { + const { manifest } = normaliseManifest({ + actions: { Spend: { inputs: [{ id: "vault_in", utxo_source: "wallet", unheard_of: 1 }] } }, + chain: "liquid", + }); + const refusal = refuseUnsupported(manifest, { + compilerVersion: "0.6.0", + contractSources: {}, + policyAsset: POLICY_ASSET, + }); + + expect(refusal?.reason).toContain("action Spend / input vault_in"); }); }); @@ -231,6 +250,8 @@ describe("what the corpus uses and this wallet does not read", () => { } } - expect([...unread].toSorted()).toEqual(["required_index"]); + // Empty, which is what this bundle set out to make true: every construct the published + // corpus uses is now one this wallet reads. + expect([...unread].toSorted()).toEqual([]); }); }); diff --git a/packages/tx-manifest/src/currency.test.ts b/packages/tx-manifest/src/currency.test.ts index d56b86e..b16e9fe 100644 --- a/packages/tx-manifest/src/currency.test.ts +++ b/packages/tx-manifest/src/currency.test.ts @@ -95,10 +95,10 @@ describe("the protocols as their authors publish them now", () => { ); expect(blocked).toEqual({ - dex: ["required_index"], - last_will: ["required_index"], - lending_v2: ["required_index"], - lending_v3: ["required_index"], + dex: [], + last_will: [], + lending_v2: [], + lending_v3: [], zeroconf: [], }); }); @@ -128,10 +128,10 @@ describe("the protocols as their authors publish them now", () => { ); expect(counted).toEqual({ - dex: { found: 4, unblocked: 3 }, - last_will: { found: 4, unblocked: 3 }, - lending_v2: { found: 9, unblocked: 3 }, - lending_v3: { found: 6, unblocked: 0 }, + dex: { found: 4, unblocked: 4 }, + last_will: { found: 4, unblocked: 4 }, + lending_v2: { found: 9, unblocked: 9 }, + lending_v3: { found: 6, unblocked: 6 }, zeroconf: { found: 0, unblocked: 0 }, }); }); diff --git a/packages/tx-manifest/src/document/inspect.test.ts b/packages/tx-manifest/src/document/inspect.test.ts index f12aaa2..d5673c8 100644 --- a/packages/tx-manifest/src/document/inspect.test.ts +++ b/packages/tx-manifest/src/document/inspect.test.ts @@ -139,7 +139,7 @@ describe("what it would refuse on, and what it never asked", () => { expect(unreachable).toContain("covenant-mismatch"); expect(unreachable).toContain("shortfall"); expect(unreachable).toContain("no-fee-rate"); - expect(unreachable).toHaveLength(9); + expect(unreachable).toHaveLength(10); }); test("names the reachable checks it could not perform, rather than passing them", () => { diff --git a/packages/tx-manifest/src/document/refuse.ts b/packages/tx-manifest/src/document/refuse.ts index 848c945..2358611 100644 --- a/packages/tx-manifest/src/document/refuse.ts +++ b/packages/tx-manifest/src/document/refuse.ts @@ -34,6 +34,8 @@ export type RejectToken = | "foreign-asset" /** A covenant this wallet cannot build or spend. */ | "unbuildable-utxo-type" + /** An input or output that cannot land at the transaction position it states. */ + | "unbuildable-position" /** The request is missing something the chosen action actually references. */ | "incomplete-request" /** The manifest declares no action by that name. */ @@ -115,6 +117,7 @@ export const NEEDS_MORE_THAN_THE_DOCUMENT_REJECTS = [ "no-fee-rate", "no-funds-at-signing-address", "shortfall", + "unbuildable-position", "document-fault", ] as const satisfies readonly RejectToken[]; diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index 3c8d168..199ef0b 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -207,10 +207,11 @@ const SITES = { // The action tolerates this input's absence. The wallet includes what it is given // and never drops one, which is inside what the declaration permits. optional: SHOWN, - // Covenants depend on input and output ordering and no implementation enforces - // this, so a manifest asking for index 0 and getting 1 builds a transaction the - // covenant rejects on chain. - required_index: UNIMPLEMENTED, + // Read, and checked against where the wallet will actually put this input. Being + // stricter than the reference implementation here is the maintainer's decision of + // 2026-08-14: a covenant reads positions, so a mismatch is rejected by the network + // after signing, and refusing before it is strictly better. + required_index: READ, sequence: READ, ui: SHOWN, utxo_source: READ, @@ -272,7 +273,7 @@ const SITES = { destination: READ, id: READ, optional: SHOWN, - required_index: UNIMPLEMENTED, + required_index: READ, ui: SHOWN, }, unknownIsLoadBearing: true, diff --git a/packages/tx-manifest/src/evaluation/positions.test.ts b/packages/tx-manifest/src/evaluation/positions.test.ts new file mode 100644 index 0000000..1530034 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/positions.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; + +import dex from "../__fixtures__/current/dex.manifest.json"; +import lendingV2 from "../__fixtures__/current/lending_v2.manifest.json"; +import lendingV3 from "../__fixtures__/current/lending_v3.manifest.json"; +import { asArray, asRecord } from "../document/json"; +import { normaliseManifest } from "../document/normalise"; +import { checkPositions } from "./positions"; + +const STATING = { dex, lending_v2: lendingV2, lending_v3: lendingV3 } as unknown as Record< + string, + Record<string, unknown> +>; + +describe("a position the document states", () => { + test("is met when the piece lands there", () => { + expect( + checkPositions([{ at: 2, id: "collateral_in", kind: "input", stated: 2 }], { + inputs: 4, + outputs: 4, + }).ok, + ).toBe(true); + }); + + // The step that cannot be skipped: a negative index counts from the end, so it cannot be + // read without knowing how many there are. Read as an absolute one it would place the + // piece somewhere else entirely. + test("counts from the end when it is negative", () => { + const last = { at: 3, id: "fee_change", kind: "output" as const, stated: -1 }; + + expect(checkPositions([last], { inputs: 2, outputs: 4 }).ok).toBe(true); + expect(checkPositions([last], { inputs: 2, outputs: 5 }).ok).toBe(false); + }); + + test("refuses by naming both the position asked for and the one it would get", () => { + const check = checkPositions([{ at: 0, id: "collateral_in", kind: "input", stated: 2 }], { + inputs: 3, + outputs: 3, + }); + + expect(check.ok).toBe(false); + + if (!check.ok) { + expect(check.reason).toContain("collateral_in"); + expect(check.reason).toContain("input 2"); + expect(check.reason).toContain("0"); + } + }); +}); + +describe("what the published protocols ask for", () => { + // Eighty-eight stated positions across the three protocols that state any, and all but two + // are simply the order the document declares things in. The two are change outputs counted + // from the end, which is why the negative form had to be read rather than treated as odd. + test("is nearly always the order they are declared in", () => { + let stated = 0; + let matchingDeclaration = 0; + let fromTheEnd = 0; + + for (const name of Object.keys(STATING)) { + const { manifest } = normaliseManifest(STATING[name]!); + + for (const action of manifest.actions) { + for (const kind of ["inputs", "outputs"] as const) { + for (const [at, entry] of asArray(action.node[kind]).entries()) { + const declared = asRecord(entry)?.required_index; + + if (typeof declared !== "number") { + continue; + } + + stated += 1; + fromTheEnd += declared < 0 ? 1 : 0; + matchingDeclaration += declared === at ? 1 : 0; + } + } + } + } + + expect({ fromTheEnd, matchingDeclaration, stated }).toEqual({ + fromTheEnd: 2, + matchingDeclaration: 86, + stated: 88, + }); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/positions.ts b/packages/tx-manifest/src/evaluation/positions.ts new file mode 100644 index 0000000..786c80c --- /dev/null +++ b/packages/tx-manifest/src/evaluation/positions.ts @@ -0,0 +1,64 @@ +/** + * Landing an input or output where the document says it must be. + * + * A covenant introspects positions: a program that asserts its collateral is input two reads + * input two, whatever the wallet meant. So a transaction built with the pieces in another + * order is not a slightly different transaction — it is one the network rejects after it has + * been signed, for a reason nobody watching the wallet could have predicted. + * + * The reference implementation parses this and checks nothing, and the corpus states it + * eighty-nine times. Being stricter here is the maintainer's decision of 2026-08-14, taken on + * the ground that a refusal before signing beats a rejection after it. + */ + +/** One position the document states, and the one the wallet's own layout produces. */ +export type StatedPosition = { + /** Where this piece actually lands, counted from the start. */ + at: number; + /** The manifest's id, so a refusal names the thing rather than a number. */ + id: string; + kind: "input" | "output"; + /** What the document asked for: from the start when positive, from the end when negative. */ + stated: number; +}; + +export type PositionCheck = { ok: true } | { ok: false; reason: string }; + +/** + * Checks every stated position against where the wallet will actually put things. + * + * A negative index counts from the end — `-1` is the last — so it cannot be read without + * knowing how many there are, which is why the totals are arguments rather than something + * this works out for itself. Getting that wrong silently would place a piece one off the end. + */ +export function checkPositions( + stated: StatedPosition[], + totals: { inputs: number; outputs: number }, +): PositionCheck { + for (const position of stated) { + const total = position.kind === "input" ? totals.inputs : totals.outputs; + const wanted = position.stated < 0 ? total + position.stated : position.stated; + + if (wanted === position.at) { + continue; + } + + return { + ok: false, + reason: + `The ${position.kind} ${position.id} must be ${position.kind} ${describe(position.stated, total)} ` + + `of this transaction, and this wallet would put it at ${position.at}. A covenant reads ` + + "positions, so a transaction built in another order is one the network rejects after " + + "it has been signed.", + }; + } + + return { ok: true }; +} + +/** How a stated position reads to someone who did not write it. */ +function describe(stated: number, total: number): string { + return stated < 0 + ? `${total + stated} — the document counts ${stated} from the end` + : `${stated}`; +} diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index 1f366a9..e883163 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -36,6 +36,7 @@ import { } from "../evaluation/issuance"; import { fillParameters } from "../evaluation/parameters"; import { planAction } from "../evaluation/plan"; +import { checkPositions, type StatedPosition } from "../evaluation/positions"; import { checkValidations } from "../evaluation/validate"; import { type StaticWitness, resolveStaticWitnesses } from "../evaluation/witness"; import { estimateFeeSats } from "../fee"; @@ -657,6 +658,72 @@ export async function reviewManifestAction( return { reason: selection.reason, refused: true, reject: "shortfall" }; } + // Where each piece actually lands, against where the document says it must. The wallet + // builds covenant inputs in the order the action declares them and then its own outputs, + // and it builds the declared outputs in order with its change last — so the layout is + // known here, and a piece that cannot land where it was asked to is refused by name. + const positions: StatedPosition[] = []; + let walletInputRank = 0; + + for (const entry of asArray(action.node.inputs)) { + const declared = asRecord(entry); + + if (!declared) { + continue; + } + + const id = typeof declared.id === "string" ? declared.id : "(unnamed)"; + const covenantAt = covenantInputs.findIndex((covenant) => covenant.id === id); + const at = covenantAt >= 0 ? covenantAt : covenantInputs.length + walletInputRank; + + if (covenantAt < 0) { + walletInputRank += 1; + } + + if (typeof declared.required_index === "number") { + positions.push({ at, id, kind: "input", stated: declared.required_index }); + } + } + + for (const [at, planned] of outputs.entries()) { + const declared = asArray(action.node.outputs) + .map((entry) => asRecord(entry)) + .find((entry) => entry?.id === planned.id); + + if (typeof declared?.required_index === "number") { + positions.push({ at, id: planned.id, kind: "output", stated: declared.required_index }); + } + } + + // Change is the wallet's own and the builder appends it last, so its position is known + // without being chosen. A document stating one for it is stating one the wallet can only + // meet by accident. + const changeOutputs = plan.plan.outputs.filter((planned) => planned.target.kind === "change"); + + for (const planned of changeOutputs) { + const declared = asArray(action.node.outputs) + .map((entry) => asRecord(entry)) + .find((entry) => entry?.id === planned.id); + + if (typeof declared?.required_index === "number") { + positions.push({ + at: outputs.length, + id: planned.id, + kind: "output", + stated: declared.required_index, + }); + } + } + + const positioned = checkPositions(positions, { + inputs: covenantInputs.length + selection.selected.length, + outputs: outputs.length + Math.min(changeOutputs.length, 1), + }); + + if (!positioned.ok) { + return { reason: positioned.reason, refused: true, reject: "unbuildable-position" }; + } + const review: ManifestReview = { action: request.action, confirmation: {} as ConfirmationModel, From ef84555729422e0e71792b899d5c2ae4fae831f0 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 12:00:52 +0300 Subject: [PATCH 113/124] test(tx-manifest): account for every construct the corpus uses, in both generations Nothing is registered unimplemented and nothing unrecognised, across all thirteen manifests this project holds. Six constructs stand deliberately unread, each with its reason written beside it in the table. --- packages/tx-manifest/src/accounting.test.ts | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/tx-manifest/src/accounting.test.ts diff --git a/packages/tx-manifest/src/accounting.test.ts b/packages/tx-manifest/src/accounting.test.ts new file mode 100644 index 0000000..9102941 --- /dev/null +++ b/packages/tx-manifest/src/accounting.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; + +import currentDex from "./__fixtures__/current/dex.manifest.json"; +import currentLastWill from "./__fixtures__/current/last_will.manifest.json"; +import currentLendingV2 from "./__fixtures__/current/lending_v2.manifest.json"; +import currentLendingV3 from "./__fixtures__/current/lending_v3.manifest.json"; +import currentZeroconf from "./__fixtures__/current/zeroconf.manifest.json"; +import dex from "./__fixtures__/dex.manifest.json"; +import lastWill from "./__fixtures__/last_will.manifest.json"; +import lending from "./__fixtures__/lending.manifest.json"; +import lendingV2 from "./__fixtures__/lending_v2.manifest.json"; +import lendingV3 from "./__fixtures__/lending_v3.manifest.json"; +import p2pkGrouped from "./__fixtures__/p2pk-grouped.manifest.json"; +import p2pk from "./__fixtures__/p2pk.manifest.json"; +import zeroconf from "./__fixtures__/zeroconf.manifest.json"; +import { normaliseManifest } from "./document/normalise"; +import { type ConstructState, describeConstructs } from "./document/registry"; + +/** + * Every manifest this project holds, in both generations. + * + * The accounting has to be over all of them rather than over the current set: a construct + * this wallet stopped reading would be invisible in a check that only looks at the newest + * documents, and two of the three generations are older ones people still deployed. + */ +const EVERY_MANIFEST = { + current_dex: currentDex, + current_last_will: currentLastWill, + current_lending_v2: currentLendingV2, + current_lending_v3: currentLendingV3, + current_zeroconf: currentZeroconf, + dex, + last_will: lastWill, + lending, + lending_v2: lendingV2, + lending_v3: lendingV3, + p2pk, + p2pk_grouped: p2pkGrouped, + zeroconf, +} as unknown as Record<string, Record<string, unknown>>; + +/** Every construct any of them declares, grouped by what this wallet makes of it. */ +function accounted(): Record<ConstructState, string[]> { + const byState = new Map<ConstructState, Set<string>>(); + + for (const name of Object.keys(EVERY_MANIFEST)) { + for (const report of describeConstructs(normaliseManifest(EVERY_MANIFEST[name]!).manifest)) { + const keys = byState.get(report.state) ?? new Set<string>(); + + keys.add(report.key); + byState.set(report.state, keys); + } + } + + return Object.fromEntries( + [...byState].map(([state, keys]) => [state, [...keys].toSorted()]), + ) as Record<ConstructState, string[]>; +} + +// The whole point of this bundle, asserted as one fact rather than inferred from nine. +describe("every construct the published corpus uses", () => { + test("is one this wallet acts on, shows, or deliberately reads for nothing", () => { + const states = accounted(); + + expect(states.unimplemented ?? []).toEqual([]); + expect(states.unrecognised ?? []).toEqual([]); + }); + + // The six that stand deliberately, each with its reason written beside it in the construct + // table. Two belong to JSON documents rather than to this format. One is a signature slot + // that does not exist and no implementation reads. One is a flag the format's newer + // generation dropped, because the block beside it says the same thing better. One is a + // field the reference implementation's own comment calls informational. And one is a + // sentence written for whoever approves the action, which interpolates values through a + // syntax no specification describes — a confident sentence about the wrong amounts changes + // what a person agrees to, so it is not shown either. + test("or, for six of them, one it names a reason for reading past", () => { + expect(accounted()["never-read"]).toEqual([ + "$comment", + "$schema", + "attestation_version", + "formula", + "intent", + "is_constructor", + ]); + }); + + test("and thirty-nine of them decide what gets signed", () => { + expect(accounted()["acted-on"]).toHaveLength(39); + }); +}); From d1387f92145df11311cf63c6348c2157f6a0b108 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 12:15:04 +0300 Subject: [PATCH 114/124] chore(smplx): branch from upstream dev, where our work was merged The fork's humid/wasm is in upstream's dev now, through their own pull request, and upstream has edited the same file since. New work starts from their branch rather than from ours. --- .gitmodules | 2 +- smplx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index dd785ef..7d356c5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,4 +5,4 @@ [submodule "smplx"] path = smplx url = https://github.com/lukachi/smplx.git - branch = humid/wasm + branch = humid/wasm-issuance diff --git a/smplx b/smplx index 92673ad..3e38e66 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 92673adc95a590352dfd2bb81a873fc3ac3aa40a +Subproject commit 3e38e66c195b1c9139a510bb7a6636b19acac3fa From d3931759c3c432b03f2d3fd14c53313051e184ef Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 12:58:26 +0300 Subject: [PATCH 115/124] Record the signing module at the commit that exposes issuance The submodule moved to a branch cut from upstream's dev, because the branch this project carried was merged there. Nothing had to be carried across: the fork revision the knowledge base cites is an ancestor of that branch, so the three consensus-visible changes -- the left-folded tap tree, the unconstrained extra taproot leaf, and the per-program build mode -- arrive with it, and all three were checked at the new branch rather than assumed. This records the commit on that branch which offers a first issuance to JavaScript on both input shapes. --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 3e38e66..e777b70 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 3e38e66c195b1c9139a510bb7a6636b19acac3fa +Subproject commit e777b70b483d489495c0de74612c12be3142398d From 096d2ea34cd7a816c40977f68f595356b1817534 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 13:04:05 +0300 Subject: [PATCH 116/124] Teach the checks what the wallet started doing Sixteen checks had been failing since outputs began being hidden, and nobody could see it: this checkout had never had both wasm packages built, so the gate that runs them could not run at all. Fifteen failed on one cause. The wallet hides an output with its own blinding key, and the stand-in for the signer was never given one, so every path that hides anything died on a missing method rather than on anything it was checking. The stand-in now has the key, and parses one it is handed the way the real module does -- as a compressed public key -- because a substitute that accepts what the module refuses buys a refusal after the person has already approved. The same parse now covers an output's asset id. The sixteenth was a refusal token that had moved. An action is looked up before anything asks what it needs, because filling a parameter needs the action that declares it, so a request naming an action the manifest does not declare is refused as a missing name. The check said otherwise and its comment described an order the code cannot have. One more, in the reader page: the refusals that need more than a document became ten when a stated transaction position joined them, and the count still said nine. The count stays a count, because that is what made this visible, and it now also names the tenth. --- .../index.test.ts | 52 ++++++++++++++++--- .../web/src/app/manifest/readDocument.test.ts | 6 ++- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 1878e49..6cfecc7 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -35,9 +35,22 @@ function requireTxid(txid: string): void { throw new Error(`Invalid txid: ${txid}`); } } + +/** + * What the real module does with a blinding key, which is parse it as a compressed public key. + * + * A substitute that took any string here would accept an output the module refuses, and the + * refusal would arrive after the person had already approved. + */ +function requireBlindingKey(what: string, value: string): void { + if (!/^0[23][0-9a-fA-F]{64}$/.test(value)) { + throw new Error(`Invalid ${what}: malformed public key`); + } +} const DERIVED = "tex1p_derived"; const WALLET_ADDRESS = "tex1q_wallet"; const WALLET_SCRIPT = "0014" + "11".repeat(20); +const BLINDING_KEY = `02${PUBKEY}`; const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; const FUNDING_TXID = "d".repeat(64); @@ -144,8 +157,13 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { // one it cannot read. Recorded rather than swallowed, so a method that stopped // stating where change goes fails here instead of sending it to the module's // own default in silence. - addChange(scriptPubKeyHex: string) { + addChange(scriptPubKeyHex: string, blindingKeyHex?: string) { requireHex("change script", scriptPubKeyHex); + + if (blindingKeyHex !== undefined) { + requireBlindingKey("change blinding key", blindingKeyHex); + } + this.change = scriptPubKeyHex; } addContractInput(txid: string, vout: number, txOutHex: string) { @@ -153,8 +171,19 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { requireTxid(txid); this.spends.push({ txid, vout }); } - addOutput(scriptPubKeyHex: string) { + addOutput( + scriptPubKeyHex: string, + _amountSats: bigint, + assetHex: string, + blindingKeyHex?: string, + ) { requireHex("output script", scriptPubKeyHex); + requireHex("asset id", assetHex); + + if (blindingKeyHex !== undefined) { + requireBlindingKey("output blinding key", blindingKeyHex); + } + recorded.paid.push(scriptPubKeyHex); } addWalletInput(txid: string, vout: number, txOut: string) { @@ -165,6 +194,12 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { free() {} }, WalletSigner: class { + // The wallet hides an output with its own blinding key, so the substitute has + // to have one. Without it every path that hides anything failed here for the + // wrong reason, which is what happened between the blinding work and now. + blindingPublicKey() { + return BLINDING_KEY; + } finalizeTransaction( builder: { change?: string; spends: { txid: string; vout: number }[] }, _feeRateSatsPerKvb: number, @@ -278,12 +313,13 @@ describe("processLiquidConfidentialTransaction", () => { (error: unknown) => error as { data?: { reject?: string } }, ); - // `incomplete-request` rather than `no-such-action`, and the difference is worth pinning: - // working out what an action needs happens before looking the action up, so a request - // naming an action the manifest does not declare is refused as a request that cannot be - // built rather than as a missing name. The sentence still says "Withdraw" — the test above - // asserts that — and the token says which check answered. - expect(failure?.data?.reject).toBe("incomplete-request"); + // `no-such-action` rather than `incomplete-request`, and the difference is worth pinning: + // the action is looked up before anything asks what it needs, because filling a + // parameter needs the action that declares it. So a request naming an action the + // manifest does not declare is refused as a missing name rather than as a request that + // cannot be built. The sentence still says "Withdraw" — the test above asserts that — + // and the token says which check answered. + expect(failure?.data?.reject).toBe("no-such-action"); }); }); diff --git a/apps/web/src/app/manifest/readDocument.test.ts b/apps/web/src/app/manifest/readDocument.test.ts index 5d83b57..ab24a33 100644 --- a/apps/web/src/app/manifest/readDocument.test.ts +++ b/apps/web/src/app/manifest/readDocument.test.ts @@ -41,7 +41,11 @@ describe("what the textarea currently holds", () => { expect(result.rewrites).toHaveLength(1); expect(result.constructs.length).toBeGreaterThan(0); - expect(result.unreachable).toHaveLength(9); + // A count rather than a list, so a refusal that stops needing more than the document — + // or a new one that does — fails here and gets looked at. The tenth is the stated + // transaction position, which needs a transaction to be unbuildable in. + expect(result.unreachable).toHaveLength(10); + expect(result.unreachable).toContain("unbuildable-position"); }); test("never asks for the checks it holds no values for", () => { From 153e72395d12d987a8da957fe6d3a50089a6ad03 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 13:11:15 +0300 Subject: [PATCH 117/124] Put the asset an action creates into the transaction that gets signed The wallet already worked out every asset an action creates: which of its own outputs each one is derived from, what the ids are, and how many units. None of it reached the signing module, so a person was shown an asset and the wallet signed a transaction that created nothing. Each planned issuance now goes onto the input it belongs to. The join is the outpoint rather than the order, because that is the only thing both sides state: the manifest named the input, the wallet chose the output, and an asset id is a function of the output rather than of where the input ended up. Either input shape can carry one -- a covenant the document points at, or an output the wallet reserved. The issuer contract is left unstated. A manifest declares none at any position, so both sides commit to the empty one and each says so rather than one inferring it from the other. An issuance whose output no input spends now refuses. It cannot happen while the outputs an issuance derives from are the ones reserved out of the funding pool, which is why it reads as an assertion about this path rather than as a refusal a document can provoke -- but the failure it prevents is an id for an asset that would never exist, shown to a person as if it would. The fee model did not know about any of this. Measured against the real module: an input carrying an issuance costs 74 vbytes more than the same input without one, so an action that creates an asset was under-priced by that much per issuance. It is a surcharge on an input already counted rather than an input of its own, and the measurement is a standing check beside the ones for every other shape. --- .../adapters/smplx/loadSmplxWasm.test.ts | 79 ++++++++- .../index.test.ts | 165 +++++++++++++++++- .../processConfidentialTransaction/index.ts | 115 ++++++++++-- packages/tx-manifest/src/fee.test.ts | 38 +++- packages/tx-manifest/src/fee.ts | 20 ++- packages/tx-manifest/src/review/index.test.ts | 2 +- packages/tx-manifest/src/review/index.ts | 3 + 7 files changed, 392 insertions(+), 30 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 8365339..f714c01 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -890,7 +890,10 @@ describe("a transaction balances against the fee that is charged", () => { // transaction balance — which is why an estimate that is merely close is safe. test("the charged fee covers the difference the wallet did not pay out", () => { const funded = 100_000n; - const estimated = estimateFeeSats({ covenantInputs: 0, outputs: 1, walletInputs: 1 }, 1000); + const estimated = estimateFeeSats( + { covenantInputs: 0, issuingInputs: 0, outputs: 1, walletInputs: 1 }, + 1000, + ); const charged = build(funded, funded - estimated, 1000); expect(charged > 0n).toBe(true); @@ -901,7 +904,10 @@ describe("a transaction balances against the fee that is charged", () => { // leaving the transaction short. test("an over-estimate leaves the transaction payable rather than short", () => { const funded = 100_000n; - const generous = estimateFeeSats({ covenantInputs: 2, outputs: 3, walletInputs: 3 }, 1000); + const generous = estimateFeeSats( + { covenantInputs: 2, issuingInputs: 0, outputs: 3, walletInputs: 3 }, + 1000, + ); expect(() => build(funded, funded - generous, 1000)).not.toThrow(); }); @@ -912,3 +918,72 @@ describe("a transaction balances against the fee that is charged", () => { expect(() => build(100_000n, 100_000n, 1000)).toThrow(); }); }); + +// The surcharge an issuance puts on the input carrying it, measured rather than modelled. +// The wallet plans the fee before anything is signed, so a model that did not know about +// issuance would under-state every action that creates an asset. +describe("what an issuance adds to the input carrying it", () => { + const TXID = "6".repeat(64); + const ISSUING_POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + + function txOut(sats: bigint, scriptHex: string): string { + const assetLe = (ISSUING_POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${len}${scriptHex}`; + } + + /** The same transaction twice, once with the funding input creating an asset. */ + function charged(issuing: boolean): bigint { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + + try { + const script = signer.scriptPubKeyHex(); + + if (issuing) { + builder + .addWalletIssuanceInput( + TXID, + 0, + txOut(100_000n, script), + 1_000n, + 0n, + undefined, + undefined, + ) + .free(); + } else { + builder.addWalletInput(TXID, 0, txOut(100_000n, script), undefined); + } + + builder.addOutput(script, 10_000n, ISSUING_POLICY_ASSET); + builder.addChange(script); + + const signed = signer.finalizeTransaction(builder, 1000); + const fee = signed.feeSats; + + signed.free(); + + return fee; + } finally { + builder.free(); + signer.free(); + } + } + + // At 1000 sat/kvb the fee charged is the vsize, so these are weights. + test("is what the model says it is", () => { + const plain = charged(false); + const issuing = charged(true); + + expect(plain).toBe( + estimateFeeSats({ covenantInputs: 0, issuingInputs: 0, outputs: 1, walletInputs: 1 }, 1000), + ); + expect(issuing).toBe( + estimateFeeSats({ covenantInputs: 0, issuingInputs: 1, outputs: 1, walletInputs: 1 }, 1000), + ); + expect(issuing - plain).toBe(74n); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 6cfecc7..b81826d 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -123,7 +123,37 @@ function context(): LiquidProcessCtContext { } as unknown as LiquidProcessCtContext; } -type Recorded = { broadcasts: { txHex: string }[]; mnemonicCalls: number; paid: string[] }; +/** + * What the module hands back when it is told to issue, which nothing on this path reads yet. + * + * It is held across the wasm boundary like everything else the module returns, so the method + * has to release it, and a substitute without `free` would let a leak pass unnoticed. + */ +function issuanceReport() { + return { + assetId: "not read yet", + entropy: "not read yet", + free() {}, + reissuanceTokenId: "not read yet", + }; +} + +/** One issuance the builder was told to put on an input, and what it was told about it. */ +type IssuedInput = { + assetAmountSats: bigint; + contractInput: boolean; + inflationAmountSats: bigint; + issuerContractHex: string | undefined; + txid: string; + vout: number; +}; + +type Recorded = { + broadcasts: { txHex: string }[]; + issued: IssuedInput[]; + mnemonicCalls: number; + paid: string[]; +}; function dependencies(recorded: Recorded): LiquidProcessCtDependencies { return { @@ -171,6 +201,32 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { requireTxid(txid); this.spends.push({ txid, vout }); } + addContractIssuanceInput( + txid: string, + vout: number, + txOutHex: string, + _source: string, + _argumentsJson: string | undefined, + _witnessJson: string | undefined, + _signatureWitness: string | undefined, + assetAmountSats: bigint, + inflationAmountSats: bigint, + issuerContractHex: string | undefined, + ) { + requireHex("covenant input's previous output", txOutHex); + requireTxid(txid); + this.spends.push({ txid, vout }); + recorded.issued.push({ + assetAmountSats, + contractInput: true, + inflationAmountSats, + issuerContractHex, + txid, + vout, + }); + + return issuanceReport(); + } addOutput( scriptPubKeyHex: string, _amountSats: bigint, @@ -191,6 +247,28 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { requireTxid(txid); this.spends.push({ txid, vout }); } + addWalletIssuanceInput( + txid: string, + vout: number, + txOut: string, + assetAmountSats: bigint, + inflationAmountSats: bigint, + issuerContractHex: string | undefined, + ) { + requireHex("wallet input's previous output", txOut); + requireTxid(txid); + this.spends.push({ txid, vout }); + recorded.issued.push({ + assetAmountSats, + contractInput: false, + inflationAmountSats, + issuerContractHex, + txid, + vout, + }); + + return issuanceReport(); + } free() {} }, WalletSigner: class { @@ -248,7 +326,7 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { } function subject() { - const recorded: Recorded = { broadcasts: [], mnemonicCalls: 0, paid: [] }; + const recorded: Recorded = { broadcasts: [], issued: [], mnemonicCalls: 0, paid: [] }; return { method: createProcessLiquidConfidentialTransaction(dependencies(recorded)), recorded }; } @@ -355,7 +433,7 @@ describe("processLiquidConfidentialTransaction across declaration shapes", () => // other part of the request was well formed. describe("processLiquidConfidentialTransaction guards what it signs", () => { function subjectSpending(extra: { txid: string; vout: number }) { - const recorded: Recorded = { broadcasts: [], mnemonicCalls: 0, paid: [] }; + const recorded: Recorded = { broadcasts: [], issued: [], mnemonicCalls: 0, paid: [] }; const dependency = dependencies(recorded); return { @@ -517,3 +595,84 @@ describe("what the outputs actually pay to", () => { expect(recorded.paid).not.toContain(DERIVED); }); }); + +/** + * The same protocol with its funding input creating an asset. + * + * Written here rather than vendored because no published manifest this wallet can build + * declares an issuance: the ones in the corpus that do reach for constructs it refuses first, + * so a fixture taken from them would assert a refusal and never reach the builder. + */ +function issuingManifest( + issuance: Record<string, unknown> = { asset_amount_sat: 1_000, kind: "new" }, +) { + const manifest = structuredClone(p2pkManifest) as unknown as { + actions: { Pay: { inputs: Record<string, unknown>[] } }; + }; + const [funding] = manifest.actions.Pay.inputs; + + if (!funding) { + throw new Error("the fixture's Pay action declares no inputs"); + } + + funding.issuance = issuance; + + return manifest; +} + +// The asset an action creates is worked out while the document is read, from an output the +// wallet commits to before anything else runs. Until now none of that reached the module, so +// the wallet showed a person an asset and signed a transaction that created nothing. +describe("an input that creates an asset", () => { + test("carries the issuance the wallet settled, on the output it was derived from", async () => { + const { method, recorded } = subject(); + + await method(params({ manifest: issuingManifest() }), context()); + + expect(recorded.issued).toEqual([ + { + assetAmountSats: 1_000n, + contractInput: false, + inflationAmountSats: 0n, + // Nothing is stated, because a manifest declares no issuer contract at any + // position. Both sides commit to the empty one and each says so. + issuerContractHex: undefined, + txid: FUNDING_TXID, + vout: 0, + }, + ]); + }); + + test("and the transaction it signs spends that same output", async () => { + const { method } = subject(); + + const result = await method(params({ manifest: issuingManifest() }), context()); + + expect(result.transactionHex).toBe(serialise([{ txid: FUNDING_TXID, vout: 0 }])); + }); + + test("while an action that creates nothing tells the builder about no issuance", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.issued).toEqual([]); + }); + + // A reissuance needs the entropy of an issuance that already happened, which reaches a + // request only on a supplied input this wallet does not read. Refused by name rather than + // minting a different asset under the protocol's name. + test("but reissuing is refused by name rather than derived from this transaction", async () => { + const { method } = subject(); + + const failure = await method( + params({ manifest: issuingManifest({ asset_amount_sat: 1_000, kind: "reissue" }) }), + context(), + ).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string } }, + ); + + expect(failure?.data?.reject).toBe("unimplemented-construct"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 8e35e85..9379833 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -186,28 +186,106 @@ export const createProcessLiquidConfidentialTransaction = ( const builder = new smplx.TransactionBuilder(); try { + // Which inputs create an asset, keyed by the output each one is derived + // from. That outpoint is the only join both sides promise: the manifest + // named the input, the wallet chose the output, and an asset id is a + // function of the output rather than of where the input ended up. Matching + // on order would be matching on something neither side states. + const issuing = new Map( + review.issuances.map((issuance) => [ + outpointKey(issuance.outpoint.txid, issuance.outpoint.vout), + issuance, + ]), + ); + const placed = new Set<string>(); + // Covenant inputs first: the manifest's own input order is what a covenant // introspects, and wallet inputs are the wallet's addition to it. for (const covenant of review.covenantInputs) { - builder.addContractInput( - covenant.txid, - covenant.vout, - covenant.txOutHex, - covenant.source, - covenant.argumentsJson, - // The values the document states outright, which is how a covenant with - // more than one branch is told which to run. A signature is not among - // them: only the signer can make one, and naming it below is what asks - // for one. Passed as the compiler's own witness shape — a type and a - // literal, both text — because the compiler is what parses SimplicityHL. - witnessValuesJson(covenant.witnessValues), - covenant.signatureWitness, - sequenceFor(review, covenant.id), - ); + const key = outpointKey(covenant.txid, covenant.vout); + const issuance = issuing.get(key); + + // The values the document states outright, which is how a covenant with + // more than one branch is told which to run. A signature is not among + // them: only the signer can make one, and naming it below is what asks + // for one. Passed as the compiler's own witness shape — a type and a + // literal, both text — because the compiler is what parses SimplicityHL. + const witness = witnessValuesJson(covenant.witnessValues); + + if (issuance) { + placed.add(key); + // The issuer contract is left unstated because a manifest declares + // none at any position, so both sides commit to nothing and each + // says so. The module reports what it made of this; reading that + // report is the next thing this path learns to do. + builder + .addContractIssuanceInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + witness, + covenant.signatureWitness, + issuance.assetAmountSats, + issuance.inflationAmountSats, + undefined, + sequenceFor(review, covenant.id), + ) + .free(); + } else { + builder.addContractInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + witness, + covenant.signatureWitness, + sequenceFor(review, covenant.id), + ); + } } for (const utxo of review.selected) { - builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); + const key = outpointKey(utxo.txid, utxo.vout); + const issuance = issuing.get(key); + + if (issuance) { + placed.add(key); + builder + .addWalletIssuanceInput( + utxo.txid, + utxo.vout, + utxo.txOut, + issuance.assetAmountSats, + issuance.inflationAmountSats, + undefined, + undefined, + ) + .free(); + } else { + builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); + } + } + + // An asset derived from an output no input spends is an id for something + // that would never exist, and the person would have been shown it. This + // cannot happen while the outputs an issuance is derived from are the ones + // reserved out of the funding pool, which is why it is an assertion about + // this path rather than a refusal a document can provoke. + const stranded = review.issuances.find( + (issuance) => + !placed.has(outpointKey(issuance.outpoint.txid, issuance.outpoint.vout)), + ); + + if (stranded) { + throw new WalletRpcInvalidParamsError( + `Input ${stranded.inputId} issues an asset from an output this ` + + "transaction does not spend, so the asset would never exist.", + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); } // An output the document wants hidden is hidden with this wallet's own blinding @@ -366,6 +444,11 @@ function sequenceFor(review: ManifestReview, id: string): number | undefined { return review.inputRules.find((rule) => rule.id === id)?.sequence; } +/** One output, written the same way on both sides of a comparison. */ +function outpointKey(txid: string, vout: number): string { + return `${txid}:${vout}`; +} + function requireNetwork(context: LiquidProcessCtContext): string { const network = SMPLX_NETWORKS[context.chain.settings.network]; diff --git a/packages/tx-manifest/src/fee.test.ts b/packages/tx-manifest/src/fee.test.ts index 8470b97..4e3443b 100644 --- a/packages/tx-manifest/src/fee.test.ts +++ b/packages/tx-manifest/src/fee.test.ts @@ -9,31 +9,53 @@ import { estimateFeeSats, estimateVsize } from "./fee"; describe("estimateVsize", () => { test("one wallet input and one output", () => { - expect(estimateVsize({ covenantInputs: 0, outputs: 1, walletInputs: 1 })).toBe(257n); + expect( + estimateVsize({ covenantInputs: 0, issuingInputs: 0, outputs: 1, walletInputs: 1 }), + ).toBe(257n); }); test("two wallet inputs and one output", () => { - expect(estimateVsize({ covenantInputs: 0, outputs: 1, walletInputs: 2 })).toBe(326n); + expect( + estimateVsize({ covenantInputs: 0, issuingInputs: 0, outputs: 1, walletInputs: 2 }), + ).toBe(326n); }); test("one wallet input and two outputs", () => { - expect(estimateVsize({ covenantInputs: 0, outputs: 2, walletInputs: 1 })).toBe(324n); + expect( + estimateVsize({ covenantInputs: 0, issuingInputs: 0, outputs: 2, walletInputs: 1 }), + ).toBe(324n); }); test("one wallet input, one covenant input and one output", () => { - expect(estimateVsize({ covenantInputs: 1, outputs: 1, walletInputs: 1 })).toBe(344n); + expect( + estimateVsize({ covenantInputs: 1, issuingInputs: 0, outputs: 1, walletInputs: 1 }), + ).toBe(344n); }); // The module charged 430 for this shape and the model says 431. It over-states rather // than under-states, which is the direction that returns the difference to the person as // change instead of taking it as a larger fee. test("a second covenant input is estimated one vbyte high, deliberately", () => { - expect(estimateVsize({ covenantInputs: 2, outputs: 1, walletInputs: 1 })).toBe(431n); + expect( + estimateVsize({ covenantInputs: 2, issuingInputs: 0, outputs: 1, walletInputs: 1 }), + ).toBe(431n); + }); + + // An issuance is a surcharge on an input that is already counted, so the same shape with + // one of its inputs creating an asset is the plain figure plus the surcharge, and not an + // input more. Measured against the real module beside the wallet's own loader checks. + test("an input that also creates an asset costs its own weight plus the issuance", () => { + expect( + estimateVsize({ covenantInputs: 0, issuingInputs: 1, outputs: 1, walletInputs: 1 }), + ).toBe(331n); + expect( + estimateVsize({ covenantInputs: 1, issuingInputs: 1, outputs: 1, walletInputs: 1 }), + ).toBe(418n); }); }); describe("estimateFeeSats", () => { - const shape = { covenantInputs: 0, outputs: 1, walletInputs: 1 }; + const shape = { covenantInputs: 0, issuingInputs: 0, outputs: 1, walletInputs: 1 }; test("at a rate of 1000 the fee is the vsize", () => { expect(estimateFeeSats(shape, 1000)).toBe(257n); @@ -44,7 +66,9 @@ describe("estimateFeeSats", () => { }); test("rounds up rather than down, so the transaction is never short", () => { - expect(estimateFeeSats({ covenantInputs: 0, outputs: 1, walletInputs: 1 }, 1)).toBe(1n); + expect( + estimateFeeSats({ covenantInputs: 0, issuingInputs: 0, outputs: 1, walletInputs: 1 }, 1), + ).toBe(1n); }); test("a fractional rate is taken at its ceiling", () => { diff --git a/packages/tx-manifest/src/fee.ts b/packages/tx-manifest/src/fee.ts index 0024380..62bc444 100644 --- a/packages/tx-manifest/src/fee.ts +++ b/packages/tx-manifest/src/fee.ts @@ -42,8 +42,25 @@ const PER_OUTPUT = 67n; */ const PER_COVENANT_INPUT = 87n; +/** + * What an issuance adds to the input carrying it. + * + * A surcharge rather than an input of its own: an issuance sits on an input that is already + * counted as a wallet or a covenant one, and adds to it the amount issued, the inflation + * keys, the entropy and the blinding nonce. + * + * Measured the same way as everything else here — an ordinary wallet input, and the same + * input carrying an issuance, both signed, and the difference in what the module charged at + * 1000 sat/kvb. Only the wallet shape was measured. The fields belong to the input rather + * than to its witness, so a covenant input carrying an issuance is priced the same, and that + * is stated here rather than measured. + */ +const PER_ISSUING_INPUT = 74n; + export type TransactionShape = { covenantInputs: number; + /** How many of those inputs also create an asset. Counted again rather than instead. */ + issuingInputs: number; outputs: number; walletInputs: number; }; @@ -54,7 +71,8 @@ export function estimateVsize(shape: TransactionShape): bigint { BASE_VSIZE + PER_WALLET_INPUT * BigInt(shape.walletInputs) + PER_OUTPUT * BigInt(shape.outputs) + - PER_COVENANT_INPUT * BigInt(shape.covenantInputs) + PER_COVENANT_INPUT * BigInt(shape.covenantInputs) + + PER_ISSUING_INPUT * BigInt(shape.issuingInputs) ); } diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts index 0ab1614..512e40a 100644 --- a/packages/tx-manifest/src/review/index.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -442,7 +442,7 @@ describe("an amount that depends on the fee", () => { // One covenant input, one output, and the wallet input the estimate assumes. expect(result.estimatedFeeSats).toBe( estimateFeeSats( - { covenantInputs: 1, outputs: 1, walletInputs: 1 }, + { covenantInputs: 1, issuingInputs: 0, outputs: 1, walletInputs: 1 }, result.feeRateSatsPerKvb, ), ); diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index e883163..3304f08 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -510,6 +510,9 @@ export async function reviewManifestAction( const estimatedFee = estimateFeeSats( { covenantInputs: covenantInputs.length, + // Each issuance is a surcharge on an input already counted above, and is known + // exactly: the outputs it derives from were committed to before this ran. + issuingInputs: issued.issuances.length, outputs: draft.plan.outputs.length, // The wallet has not chosen the rest of its inputs yet, and one is the common case; a // selection that takes more is priced below, before anything is committed to. The From 1d6b3fcbca2632548af9531290b9a7e5ab1de0c5 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 13:16:09 +0300 Subject: [PATCH 118/124] Refuse when the module disagrees about the asset it issued The asset an action creates is the first fact the wallet and the signing module each work out for themselves, from the same output. Until now only the wallet's answer existed. Now both do, and they are compared before anything is signed. All three values, because two agreeing while the third does not is still a disagreement about what is being created. Both sides are lowered before they are compared: normalising one side only is a comparison that can pass while the values differ. A difference refuses. Not a warning and not a preference for one of the two: a silent disagreement means one of them is creating a different asset than the other, and nothing downstream could tell which. The refusal names which value disagreed, what the wallet derived and what the module reported. It carries a wire code of its own. Every refusal on this path shares one error shape, so without a token a site has to parse English to tell "try again later" from "this will never work" -- and this one never is worth retrying: it is not the document's fault and not a construct nobody implemented, it is the wallet and the module underneath it disagreeing. Two refusals beside it that had no token now carry the same one: an issuance derived from an output the transaction does not spend, and a finished transaction that spends more than was asked for. All three say the same thing, which is that what came back is not what was agreed. --- .../index.test.ts | 114 +++++++++++++++--- .../processConfidentialTransaction/index.ts | 76 ++++++++++-- .../web/src/app/manifest/readDocument.test.ts | 6 +- .../tx-manifest/src/document/inspect.test.ts | 5 +- packages/tx-manifest/src/document/refuse.ts | 12 +- 5 files changed, 183 insertions(+), 30 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index b81826d..0d9e9ed 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -124,18 +124,28 @@ function context(): LiquidProcessCtContext { } /** - * What the module hands back when it is told to issue, which nothing on this path reads yet. + * What the module reports for the one output these checks issue from. * - * It is held across the wasm boundary like everything else the module returns, so the method - * has to release it, and a substitute without `free` would let a leak pass unnoticed. + * Written out rather than computed with the wallet's own function. The point of the check is + * that two independent derivations agree, and a substitute that called the wallet's would + * agree by construction and prove nothing. These are the values the wallet derives for + * `FUNDING_TXID:0` committing to no issuer contract, so a change on either side fails here. */ -function issuanceReport() { - return { - assetId: "not read yet", - entropy: "not read yet", - free() {}, - reissuanceTokenId: "not read yet", - }; +const ISSUED: IssuanceAccount = { + assetId: "3e04c1072681d13b140419b4e1acf7084daa94fbde3accb80321ae6e8badb057", + entropy: "d95f2c5c8e8eacb0581b8ea00e403e826049dcedbff88f3b9e609b3020e65978", + reissuanceTokenId: "cfc308991457ed32a50cc8494cdbad89d61a9e8c3380f4ef884c0a5d82002c9e", +}; + +type IssuanceAccount = { assetId: string; entropy: string; reissuanceTokenId: string }; + +/** + * The module's account of one issuance, held across the wasm boundary like everything else it + * returns — so the method has to release it, and a substitute without `free` would let a leak + * pass unnoticed. + */ +function issuanceReport(account: IssuanceAccount) { + return { ...account, free() {} }; } /** One issuance the builder was told to put on an input, and what it was told about it. */ @@ -155,7 +165,10 @@ type Recorded = { paid: string[]; }; -function dependencies(recorded: Recorded): LiquidProcessCtDependencies { +function dependencies( + recorded: Recorded, + issued: IssuanceAccount = ISSUED, +): LiquidProcessCtDependencies { return { broadcastTransaction: async ({ txHex }) => { recorded.broadcasts.push({ txHex }); @@ -225,7 +238,7 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { vout, }); - return issuanceReport(); + return issuanceReport(issued); } addOutput( scriptPubKeyHex: string, @@ -267,7 +280,7 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { vout, }); - return issuanceReport(); + return issuanceReport(issued); } free() {} }, @@ -325,10 +338,13 @@ function dependencies(recorded: Recorded): LiquidProcessCtDependencies { }; } -function subject() { +function subject(issued: IssuanceAccount = ISSUED) { const recorded: Recorded = { broadcasts: [], issued: [], mnemonicCalls: 0, paid: [] }; - return { method: createProcessLiquidConfidentialTransaction(dependencies(recorded)), recorded }; + return { + method: createProcessLiquidConfidentialTransaction(dependencies(recorded, issued)), + recorded, + }; } describe("processLiquidConfidentialTransaction", () => { @@ -676,3 +692,71 @@ describe("an input that creates an asset", () => { expect(failure?.data?.reject).toBe("unimplemented-construct"); }); }); + +// The asset an action creates is the first fact the wallet and the signing module each work +// out for themselves, from the same output. They should agree, and a silent disagreement +// means one of them is creating a different asset than the other with nothing downstream able +// to tell which. +describe("when the module disagrees about the asset it issued", () => { + test("the two derivations agreeing is what lets the transaction be signed", async () => { + const { method } = subject(); + + const result = await method(params({ manifest: issuingManifest() }), context()); + + expect(result.transactionHex).toBe(serialise([{ txid: FUNDING_TXID, vout: 0 }])); + }); + + test("a different asset refuses, and says which value disagreed", async () => { + const { method } = subject({ ...ISSUED, assetId: `${"0".repeat(63)}1` }); + + const failure = await method(params({ manifest: issuingManifest() }), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("asset"); + expect(failure?.message).toContain(ISSUED.assetId); + }); + + // Two of the three agreeing is still a disagreement about what is being created, so each + // one is its own case rather than the asset standing in for all three. + test("a different reissuance token refuses too", async () => { + const { method } = subject({ ...ISSUED, reissuanceTokenId: `${"0".repeat(63)}2` }); + + const failure = await method(params({ manifest: issuingManifest() }), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("reissuance token"); + }); + + test("and so does a different entropy", async () => { + const { method } = subject({ ...ISSUED, entropy: `${"0".repeat(63)}3` }); + + const failure = await method(params({ manifest: issuingManifest() }), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("entropy"); + }); + + // The wallet's own derivation is what decides. A comparison that lowered one side only + // could pass while the values differ, so both are lowered and the same value written the + // other way round is still the same value. + test("the same value in another case is not a disagreement", async () => { + const { method } = subject({ + assetId: ISSUED.assetId.toUpperCase(), + entropy: ISSUED.entropy.toUpperCase(), + reissuanceTokenId: ISSUED.reissuanceTokenId.toUpperCase(), + }); + + const result = await method(params({ manifest: issuingManifest() }), context()); + + expect(result.transactionHex).toBe(serialise([{ txid: FUNDING_TXID, vout: 0 }])); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 9379833..0ea3164 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -8,6 +8,7 @@ import { parseLiquidProcessCtParams, type ReadFeeRate, type ReadTxOut, + type RejectToken, reviewManifestAction, toShownConfirmation, } from "@humid/tx-manifest"; @@ -199,6 +200,38 @@ export const createProcessLiquidConfidentialTransaction = ( ); const placed = new Set<string>(); + // The module derives the asset for itself, from the same output, and reports + // what it made of it. This is the first fact the wallet and the module each + // establish independently, so it gets the treatment every other such fact + // gets: they are compared, and a difference refuses rather than one of the + // two being trusted. A silent disagreement means one of them is creating a + // different asset than the other, and nothing downstream could tell which. + const agreeOrRefuse = ( + issuance: (typeof review.issuances)[number], + reported: { + assetId: string; + entropy: string; + free: () => void; + reissuanceTokenId: string; + }, + ) => { + try { + const difference = firstDisagreement(issuance, reported); + + if (difference) { + throw new WalletRpcInvalidParamsError( + `Input ${issuance.inputId} creates an asset the signing module ` + + `does not agree about: the ${difference.what} the wallet derived ` + + `is ${difference.mine} and the module reports ${difference.theirs}.`, + { reject: "built-something-else" satisfies RejectToken }, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + } finally { + reported.free(); + } + }; + // Covenant inputs first: the manifest's own input order is what a covenant // introspects, and wallet inputs are the wallet's addition to it. for (const covenant of review.covenantInputs) { @@ -216,10 +249,10 @@ export const createProcessLiquidConfidentialTransaction = ( placed.add(key); // The issuer contract is left unstated because a manifest declares // none at any position, so both sides commit to nothing and each - // says so. The module reports what it made of this; reading that - // report is the next thing this path learns to do. - builder - .addContractIssuanceInput( + // says so. + agreeOrRefuse( + issuance, + builder.addContractIssuanceInput( covenant.txid, covenant.vout, covenant.txOutHex, @@ -231,8 +264,8 @@ export const createProcessLiquidConfidentialTransaction = ( issuance.inflationAmountSats, undefined, sequenceFor(review, covenant.id), - ) - .free(); + ), + ); } else { builder.addContractInput( covenant.txid, @@ -253,8 +286,9 @@ export const createProcessLiquidConfidentialTransaction = ( if (issuance) { placed.add(key); - builder - .addWalletIssuanceInput( + agreeOrRefuse( + issuance, + builder.addWalletIssuanceInput( utxo.txid, utxo.vout, utxo.txOut, @@ -262,8 +296,8 @@ export const createProcessLiquidConfidentialTransaction = ( issuance.inflationAmountSats, undefined, undefined, - ) - .free(); + ), + ); } else { builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); } @@ -283,7 +317,7 @@ export const createProcessLiquidConfidentialTransaction = ( throw new WalletRpcInvalidParamsError( `Input ${stranded.inputId} issues an asset from an output this ` + "transaction does not spend, so the asset would never exist.", - undefined, + { reject: "built-something-else" satisfies RejectToken }, WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, ); } @@ -449,6 +483,26 @@ function outpointKey(txid: string, vout: number): string { return `${txid}:${vout}`; } +/** + * What the module says it issued, against what the wallet derived, in one comparison. + * + * All three values, because two of them agreeing while the third does not is still a + * disagreement about what is being created. Both sides are lowered before they are compared: + * normalising one side only is a comparison that can pass while the values differ. + */ +function firstDisagreement( + planned: { asset: string; entropy: string; reissuanceToken: string }, + reported: { assetId: string; entropy: string; reissuanceTokenId: string }, +): { mine: string; theirs: string; what: string } | undefined { + const compared = [ + { mine: planned.asset, theirs: reported.assetId, what: "asset" }, + { mine: planned.reissuanceToken, theirs: reported.reissuanceTokenId, what: "reissuance token" }, + { mine: planned.entropy, theirs: reported.entropy, what: "entropy" }, + ]; + + return compared.find(({ mine, theirs }) => mine.toLowerCase() !== theirs.toLowerCase()); +} + function requireNetwork(context: LiquidProcessCtContext): string { const network = SMPLX_NETWORKS[context.chain.settings.network]; diff --git a/apps/web/src/app/manifest/readDocument.test.ts b/apps/web/src/app/manifest/readDocument.test.ts index ab24a33..ab37ac4 100644 --- a/apps/web/src/app/manifest/readDocument.test.ts +++ b/apps/web/src/app/manifest/readDocument.test.ts @@ -43,9 +43,11 @@ describe("what the textarea currently holds", () => { expect(result.constructs.length).toBeGreaterThan(0); // A count rather than a list, so a refusal that stops needing more than the document — // or a new one that does — fails here and gets looked at. The tenth is the stated - // transaction position, which needs a transaction to be unbuildable in. - expect(result.unreachable).toHaveLength(10); + // transaction position, which needs a transaction to be unbuildable in; the eleventh is + // the signing module disagreeing with the wallet, which needs one to have been built. + expect(result.unreachable).toHaveLength(11); expect(result.unreachable).toContain("unbuildable-position"); + expect(result.unreachable).toContain("built-something-else"); }); test("never asks for the checks it holds no values for", () => { diff --git a/packages/tx-manifest/src/document/inspect.test.ts b/packages/tx-manifest/src/document/inspect.test.ts index d5673c8..3029128 100644 --- a/packages/tx-manifest/src/document/inspect.test.ts +++ b/packages/tx-manifest/src/document/inspect.test.ts @@ -139,7 +139,10 @@ describe("what it would refuse on, and what it never asked", () => { expect(unreachable).toContain("covenant-mismatch"); expect(unreachable).toContain("shortfall"); expect(unreachable).toContain("no-fee-rate"); - expect(unreachable).toHaveLength(10); + // The signing module disagreeing with the wallet is decided after a transaction has + // been built, which is further from a document than any of the others here. + expect(unreachable).toContain("built-something-else"); + expect(unreachable).toHaveLength(11); }); test("names the reachable checks it could not perform, rather than passing them", () => { diff --git a/packages/tx-manifest/src/document/refuse.ts b/packages/tx-manifest/src/document/refuse.ts index 2358611..0cb7dd1 100644 --- a/packages/tx-manifest/src/document/refuse.ts +++ b/packages/tx-manifest/src/document/refuse.ts @@ -53,7 +53,16 @@ export type RejectToken = /** The wallet holds less than the action needs, in the form the action can spend. */ | "shortfall" /** An expression, encoding or protocol rule the manifest states could not be satisfied. */ - | "document-fault"; + | "document-fault" + /** + * The signing module's account of what it built is not what the wallet agreed to. + * + * Nothing a site can correct and never worth retrying: the document was read, the action + * resolved, and the person may already have approved. What failed is the agreement between + * the wallet and the module underneath it, and the wallet returns nothing rather than a + * transaction it cannot vouch for. + */ + | "built-something-else"; export type Refusal = { reason: string; @@ -119,6 +128,7 @@ export const NEEDS_MORE_THAN_THE_DOCUMENT_REJECTS = [ "shortfall", "unbuildable-position", "document-fault", + "built-something-else", ] as const satisfies readonly RejectToken[]; /** From 0e0e5f94a5cff8f4c149a306035c003ab7eb3ed4 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 14:20:58 +0300 Subject: [PATCH 119/124] Move the compiler version to where both readers can reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version of the SimplicityHL compiler the shipped signing module builds with lived inside the extension. The dapp's manifest inspector runs the same reader the wallet does and has to compare a protocol's declared version against the same value, and it cannot reach into apps/extension — so it supplied nothing and the reader reported that check as not run. It moves to a package of its own rather than into @humid/tx-manifest, which describes itself as wallet-agnostic and takes this version as an argument precisely so it never has to know one. The drift check moves with it, and gained an assertion on the way. It finds the signing module by counting directories upward, and a wrong count does not have to land on nothing: a clone of the submodule beside the repository is an ordinary thing to have, and the wrong count found one and passed against it. The check now confirms the root it counted to is this workspace before reading anything out of it. The page becomes the second reader separately. Nothing about what the wallet checks changes. --- .../processConfidentialTransaction/index.ts | 2 +- bun.lock | 7 +++++++ package.json | 1 + packages/smplx-compiler/package.json | 11 +++++++++++ .../smplx-compiler/src}/compilerVersion.test.ts | 15 ++++++++++++++- .../smplx-compiler/src}/compilerVersion.ts | 12 ++++++++++-- 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 packages/smplx-compiler/package.json rename {apps/extension/src/core/chains/liquid/adapters/smplx => packages/smplx-compiler/src}/compilerVersion.test.ts (59%) rename {apps/extension/src/core/chains/liquid/adapters/smplx => packages/smplx-compiler/src}/compilerVersion.ts (61%) diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 0ea3164..2a73f6c 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -1,3 +1,4 @@ +import { SMPLX_COMPILER_VERSION } from "@humid/smplx-compiler"; import { createEsploraFeeRateReader, createEsploraTxOutReader, @@ -20,7 +21,6 @@ import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; import { toScriptPubKeyHex } from "../../../adapters/lwk/wallet/toScriptPubKeyHex"; import { withAccountMnemonic } from "../../../adapters/lwk/wallet/withAccountMnemonic"; -import { SMPLX_COMPILER_VERSION } from "../../../adapters/smplx/compilerVersion"; import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; import { LIQUID_WALLET_RPC_METHODS } from "../../../domain/LiquidRpc"; diff --git a/bun.lock b/bun.lock index 6d96918..bf275a0 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@hookform/resolvers": "^5.4.0", "@hugeicons/core-free-icons": "^4.2.0", "@hugeicons/react": "^1.1.6", + "@humid/smplx-compiler": "workspace:*", "@humid/tx-manifest": "workspace:*", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", @@ -169,6 +170,10 @@ "@walletconnect/universal-provider": ">=2.0.0", }, }, + "packages/smplx-compiler": { + "name": "@humid/smplx-compiler", + "version": "1.0.0", + }, "packages/tx-manifest": { "name": "@humid/tx-manifest", "version": "1.0.0", @@ -414,6 +419,8 @@ "@humid/appkit-injected-adapter": ["@humid/appkit-injected-adapter@workspace:packages/appkit-injected-adapter"], + "@humid/smplx-compiler": ["@humid/smplx-compiler@workspace:packages/smplx-compiler"], + "@humid/tx-manifest": ["@humid/tx-manifest@workspace:packages/tx-manifest"], "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="], diff --git a/package.json b/package.json index 7e94635..f38985d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@hookform/resolvers": "^5.4.0", "@hugeicons/core-free-icons": "^4.2.0", "@hugeicons/react": "^1.1.6", + "@humid/smplx-compiler": "workspace:*", "@humid/tx-manifest": "workspace:*", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", diff --git a/packages/smplx-compiler/package.json b/packages/smplx-compiler/package.json new file mode 100644 index 0000000..2b0e7d9 --- /dev/null +++ b/packages/smplx-compiler/package.json @@ -0,0 +1,11 @@ +{ + "name": "@humid/smplx-compiler", + "version": "1.0.0", + "private": true, + "description": "Which SimplicityHL compiler the shipped signing module builds contracts with. One statement, guarded against the submodule it describes, for every surface that has to compare a protocol's declared version against this wallet's.", + "type": "module", + "types": "./src/compilerVersion.ts", + "exports": { + ".": "./src/compilerVersion.ts" + } +} diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.test.ts b/packages/smplx-compiler/src/compilerVersion.test.ts similarity index 59% rename from apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.test.ts rename to packages/smplx-compiler/src/compilerVersion.test.ts index 8603458..259cb9d 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.test.ts +++ b/packages/smplx-compiler/src/compilerVersion.test.ts @@ -9,7 +9,10 @@ import { SMPLX_COMPILER_VERSION } from "./compilerVersion"; // says which Rust dependency produced it, so the only thing that can contradict the constant // is the manifest the submodule is built from — which is what this reads. describe("the compiler version the wallet claims", () => { - const workspaceManifest = join(import.meta.dir, "../../../../../../../..", "smplx", "Cargo.toml"); + // Relative to this file rather than to the process, because the whole suite runs from the + // repository root and a package's own test must not depend on being run from there. + const repositoryRoot = join(import.meta.dir, "../../.."); + const workspaceManifest = join(repositoryRoot, "smplx", "Cargo.toml"); function pinnedVersion(): string { const manifest = readFileSync(workspaceManifest, "utf8"); @@ -22,6 +25,16 @@ describe("the compiler version the wallet claims", () => { return pin[1]; } + // A wrong number of levels does not have to land on nothing. Cloning the signing module + // beside its consumer is a normal thing to have done, and while this file was being moved a + // wrong count found exactly that and passed against a manifest from another checkout. So the + // root is checked to be this workspace before anything is read out of it. + it("reads the submodule of this repository rather than another copy of it", () => { + const workspace = readFileSync(join(repositoryRoot, "package.json"), "utf8"); + + expect(JSON.parse(workspace).name).toBe("humid-workspace"); + }); + it("matches the version the submodule's workspace pins", () => { expect(SMPLX_COMPILER_VERSION).toBe(pinnedVersion()); }); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.ts b/packages/smplx-compiler/src/compilerVersion.ts similarity index 61% rename from apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.ts rename to packages/smplx-compiler/src/compilerVersion.ts index 20ef957..0f93336 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/compilerVersion.ts +++ b/packages/smplx-compiler/src/compilerVersion.ts @@ -5,8 +5,16 @@ * contracts were built with, and the wallet refuses one it cannot reproduce — so the value * exists to be compared against a manifest, which is a wallet concern the SDK has no reason * to carry. It lived in the fork as a constant, then as a build script reading the workspace - * manifest, and upstream asked for both to go; keeping the fact here rather than defending it - * in someone else's crate is the smaller fork and the more honest home. + * manifest, and upstream asked for both to go; keeping the fact on this side rather than + * defending it in someone else's crate is the smaller fork and the more honest home. + * + * It has a package of its own because two surfaces compare a protocol's declared version + * against it: the extension, when it decides whether to build an action, and the dapp's + * manifest inspector, which tells a protocol author what this wallet would make of their + * document. The inspector cannot reach inside the extension, and a second copy written down + * for it would be a second thing to keep true. Deliberately not inside the package that reads + * manifests: that one is wallet-agnostic and takes this version as an argument precisely so it + * never has to know one. * * Written down rather than derived at runtime because there is nothing to derive it from: the * module is a wasm blob and the version is a Rust dependency of the crate that built it. What From eb0ba087dd083cede7ff67891e389e83939956ed Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 14:29:19 +0300 Subject: [PATCH 120/124] Ask which network, and run the two checks that need its asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inspector ran the same reader the wallet does and handed it no policy asset, so the two checks that compare against the asset a network charges in were reported as not run. Three published protocols — dex and both lending generations — are refused by the wallet on the asset they move, and the page showed all three clean. The network is asked for rather than read from anywhere: a document names a chain family, and the two Liquid networks charge in different assets. Nothing is chosen when the page opens, and until something is the two checks stay reported as not run rather than passing by default. The dapp already knew both assets, chain-qualified and inside the dashboard. The bare id — the spelling the reader and the wallet both compare — moves to where both features reach it, and the dashboard's longer spelling is built from it. The panel listing unrun checks now says why each one was not run. It used to carry a single sentence about the compiler, which was the only unrun check it could explain. --- apps/web/src/app/dashboard/lib/constants.ts | 9 ++- .../app/manifest/components/RefusalPanel.tsx | 44 ++++++++++-- apps/web/src/app/manifest/index.test.tsx | 10 +++ apps/web/src/app/manifest/index.tsx | 46 ++++++++++++- .../web/src/app/manifest/readDocument.test.ts | 68 +++++++++++++++++++ apps/web/src/app/manifest/readDocument.ts | 26 +++++-- apps/web/src/lib/liquid-networks.ts | 40 +++++++++++ 7 files changed, 229 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/lib/liquid-networks.ts diff --git a/apps/web/src/app/dashboard/lib/constants.ts b/apps/web/src/app/dashboard/lib/constants.ts index 491cd1a..9f7afdf 100644 --- a/apps/web/src/app/dashboard/lib/constants.ts +++ b/apps/web/src/app/dashboard/lib/constants.ts @@ -1,7 +1,12 @@ import { LIQUID_MAINNET_CHAIN_ID, LIQUID_TESTNET_CHAIN_ID } from "@humid/appkit-injected-adapter"; -export const LIQUID_MAINNET_LBTC_ASSET_ID = `${LIQUID_MAINNET_CHAIN_ID}/elip144:6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d`; -export const LIQUID_TESTNET_LBTC_ASSET_ID = `${LIQUID_TESTNET_CHAIN_ID}/elip144:144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49`; +import { LIQUID_MAINNET, LIQUID_TESTNET } from "@/lib/liquid-networks"; + +// Chain-qualified for the wallet RPC, from the same asset the manifest inspector compares +// against. Written once: two spellings of one fact drift, and the wrong one refuses a document +// that should have built. +export const LIQUID_MAINNET_LBTC_ASSET_ID = `${LIQUID_MAINNET_CHAIN_ID}/elip144:${LIQUID_MAINNET.policyAsset}`; +export const LIQUID_TESTNET_LBTC_ASSET_ID = `${LIQUID_TESTNET_CHAIN_ID}/elip144:${LIQUID_TESTNET.policyAsset}`; export const DEFAULT_IDENTITY = "ssh://humid@localhost"; export const DEFAULT_IDENTITY_CHALLENGE = diff --git a/apps/web/src/app/manifest/components/RefusalPanel.tsx b/apps/web/src/app/manifest/components/RefusalPanel.tsx index 2297954..b593294 100644 --- a/apps/web/src/app/manifest/components/RefusalPanel.tsx +++ b/apps/web/src/app/manifest/components/RefusalPanel.tsx @@ -1,4 +1,4 @@ -import type { ManifestInspection } from "@humid/tx-manifest"; +import type { ManifestInspection, RejectToken } from "@humid/tx-manifest"; import { Badge } from "@/components/ui/badge"; @@ -60,33 +60,65 @@ export function RefusalPanel({ {inspection.skipped.length > 0 && ( <Unasked heading="Not checked, because this page was not told what it needs" - explanation="The compiler check needs the single SimplicityHL version a wallet ships, and this page ships none." + explanations={whyUnasked(inspection.skipped)} tokens={inspection.skipped} /> )} <Unasked heading="Not checkable from a document at all" - explanation="Each of these is decided against money, a chain read, a fee rate or a filled request. Reading a document establishes nothing about any of them." + explanations={[ + "Each of these is decided against money, a chain read, a fee rate or a filled request. Reading a document establishes nothing about any of them.", + ]} tokens={inspection.unreachable} /> </div> ); } +/** + * Why each unrun check was not run, in the reader's own terms. + * + * One sentence per missing input rather than per check, because two of them are missing the + * same thing and a person reading this is deciding what to do about it. Where the answer is + * theirs to give, the sentence says so — an explanation that only states what is absent + * leaves the page looking broken rather than waiting. + */ +function whyUnasked(skipped: readonly RejectToken[]): string[] { + const explanations: string[] = []; + + if (skipped.includes("foreign-compiler")) { + explanations.push( + "The compiler check needs the single SimplicityHL version a wallet ships, and this page ships none.", + ); + } + + if (skipped.includes("foreign-asset") || skipped.includes("unbuildable-utxo-type")) { + explanations.push( + "The asset checks need the asset the network charges in, and no network is chosen. Choose one above and they run.", + ); + } + + return explanations; +} + function Unasked({ - explanation, + explanations, heading, tokens, }: { - explanation: string; + explanations: readonly string[]; heading: string; tokens: readonly string[]; }) { return ( <section className="flex flex-col gap-2"> <h3 className="text-sm font-medium">{heading}</h3> - <p className="text-muted-foreground text-xs">{explanation}</p> + {explanations.map((explanation) => ( + <p key={explanation} className="text-muted-foreground text-xs"> + {explanation} + </p> + ))} <div className="flex flex-wrap gap-1"> {tokens.map((token) => ( <Badge key={token} variant="outline" className="font-mono"> diff --git a/apps/web/src/app/manifest/index.test.tsx b/apps/web/src/app/manifest/index.test.tsx index 7101c32..2127fe7 100644 --- a/apps/web/src/app/manifest/index.test.tsx +++ b/apps/web/src/app/manifest/index.test.tsx @@ -35,4 +35,14 @@ describe("the inspector with nothing around it", () => { test("offers a document to start from, so the empty box is not the only way in", () => { expect(renderToStaticMarkup(<ManifestInspector />)).toContain("Load the p2pk example"); }); + + // AC-01's other half. The network is the one thing the page asks for, and it opens without an + // answer — a default here would be a guess that decides whether two checks refuse. + test("asks which network, and opens with none chosen", () => { + const html = renderToStaticMarkup(<ManifestInspector />); + + expect(html).toContain("Network"); + expect(html).toContain("Not chosen"); + expect(html).not.toContain("Liquid Testnet"); + }); }); diff --git a/apps/web/src/app/manifest/index.tsx b/apps/web/src/app/manifest/index.tsx index d869b0e..76093de 100644 --- a/apps/web/src/app/manifest/index.tsx +++ b/apps/web/src/app/manifest/index.tsx @@ -3,13 +3,29 @@ import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { LIQUID_NETWORKS, liquidNetworkByChainId } from "@/lib/liquid-networks"; import { ConstructTable } from "./components/ConstructTable"; import { RefusalPanel } from "./components/RefusalPanel"; import { RewriteList } from "./components/RewriteList"; import { readDocument } from "./readDocument"; +/** + * Going back to no answer, which needs a value of its own because the empty string is how the + * select spells "nothing chosen yet" and cannot also be an option. Neither resolves to a + * network, which is the only thing the reader is told. + */ +const NO_NETWORK = "none"; + /** * What this wallet makes of a txManifest document, without building anything from it. * @@ -21,10 +37,17 @@ import { readDocument } from "./readDocument"; * * It connects to nothing. There is no wallet here, no chain read and no request, which is * both the point and the limit: see {@link RefusalPanel} for what that costs. + * + * The network is asked for rather than read from anywhere, because a document names a chain + * family and the two Liquid networks charge in different assets. Unanswered is a real state + * and the one it opens in — the checks that need that asset are then reported as not run, + * which is not the same as passing them. */ export default function ManifestInspector() { const [text, setText] = useState(""); - const document = useMemo(() => readDocument(text), [text]); + const [chosenChain, setChosenChain] = useState(""); + const network = useMemo(() => liquidNetworkByChainId(chosenChain), [chosenChain]); + const document = useMemo(() => readDocument(text, { network }), [text, network]); return ( <div className="mx-auto flex min-h-svh w-full max-w-4xl flex-col gap-6 p-4 md:p-6"> @@ -37,6 +60,27 @@ export default function ManifestInspector() { </CardDescription> </CardHeader> <CardContent className="flex flex-col gap-3"> + <div className="flex flex-col gap-2"> + <Label htmlFor="manifest-network">Network</Label> + <Select value={chosenChain} onValueChange={setChosenChain}> + <SelectTrigger id="manifest-network" className="w-72"> + <SelectValue placeholder="Not chosen" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={NO_NETWORK}>Not chosen</SelectItem> + {LIQUID_NETWORKS.map((candidate) => ( + <SelectItem key={candidate.chainId} value={candidate.chainId}> + {candidate.name} + </SelectItem> + ))} + </SelectContent> + </Select> + <p className="text-muted-foreground text-xs"> + A document says which chain family it is written for, never which network — and the + two Liquid networks charge in different assets. Until you say which, the two checks + that compare against that asset are reported as not run rather than passed. + </p> + </div> <Textarea value={text} onChange={(event) => setText(event.target.value)} diff --git a/apps/web/src/app/manifest/readDocument.test.ts b/apps/web/src/app/manifest/readDocument.test.ts index ab37ac4..5fa5041 100644 --- a/apps/web/src/app/manifest/readDocument.test.ts +++ b/apps/web/src/app/manifest/readDocument.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test"; +import dexManifest from "@humid/tx-manifest/fixtures/current/dex.manifest.json"; +import lastWillManifest from "@humid/tx-manifest/fixtures/current/last_will.manifest.json"; +import lendingV2Manifest from "@humid/tx-manifest/fixtures/current/lending_v2.manifest.json"; +import lendingV3Manifest from "@humid/tx-manifest/fixtures/current/lending_v3.manifest.json"; +import zeroconfManifest from "@humid/tx-manifest/fixtures/current/zeroconf.manifest.json"; + +import { LIQUID_MAINNET, LIQUID_TESTNET } from "@/lib/liquid-networks"; + import { readDocument } from "./readDocument"; // The page's own half of AC-07. The package decides whether parsed JSON is a document; this @@ -62,3 +70,63 @@ describe("what the textarea currently holds", () => { expect(result.skipped).toEqual(["foreign-compiler", "foreign-asset", "unbuildable-utxo-type"]); }); }); + +// AC-01 and AC-03. The asset checks are the two the wallet already refuses published protocols +// on, so what matters is not that a value arrives but that the same documents come back refused +// here as there — and that no network still means no answer rather than a passing one. + +describe("the two checks that need the network's own asset", () => { + function read(manifest: unknown, network?: typeof LIQUID_MAINNET) { + const result = readDocument(JSON.stringify(manifest), { network }); + + if (result.kind !== "read" || !result.ok) { + throw new Error("expected a readable document"); + } + + return result; + } + + test("with no network chosen, both stay reported as not run", () => { + const result = read(dexManifest); + + expect(result.skipped).toContain("foreign-asset"); + expect(result.skipped).toContain("unbuildable-utxo-type"); + expect(result.refusal).toBeUndefined(); + }); + + test("with a network chosen, neither is reported as not run any more", () => { + const result = read(zeroconfManifest, LIQUID_MAINNET); + + // The compiler check still is; this page holds no version yet. + expect(result.skipped).toEqual(["foreign-compiler"]); + }); + + // The three the wallet refuses on the asset they move. Named rather than counted: a document + // dropping out of this list is the page and the wallet disagreeing again, which is the whole + // failure this page exists to prevent. + test.each([ + ["dex", dexManifest], + ["lending_v2", lendingV2Manifest], + ["lending_v3", lendingV3Manifest], + ])("%s is refused on the asset it moves", (_name, manifest) => { + expect(read(manifest, LIQUID_MAINNET).refusal?.reject).toBe("foreign-asset"); + }); + + test.each([ + ["last_will", lastWillManifest], + ["zeroconf", zeroconfManifest], + ])("%s is not refused, and is not claimed to be unchecked either", (_name, manifest) => { + const result = read(manifest, LIQUID_MAINNET); + + expect(result.refusal).toBeUndefined(); + expect(result.skipped).not.toContain("foreign-asset"); + }); + + // Both networks carry a different asset and the answer happens to be the same, because these + // documents name assets no Liquid network charges in. Asserted so that stops being invisible + // if it ever changes. + test("testnet reaches the same verdict as mainnet on the published corpus", () => { + expect(read(dexManifest, LIQUID_TESTNET).refusal?.reject).toBe("foreign-asset"); + expect(read(lastWillManifest, LIQUID_TESTNET).refusal).toBeUndefined(); + }); +}); diff --git a/apps/web/src/app/manifest/readDocument.ts b/apps/web/src/app/manifest/readDocument.ts index 8edbdff..38e7f3f 100644 --- a/apps/web/src/app/manifest/readDocument.ts +++ b/apps/web/src/app/manifest/readDocument.ts @@ -1,5 +1,7 @@ import { inspectManifestDocument, type InspectManifestResult } from "@humid/tx-manifest"; +import type { LiquidNetwork } from "@/lib/liquid-networks"; + /** Nothing has been pasted yet, which is not a fault to report. */ export type EmptyDocument = { kind: "empty" }; @@ -11,6 +13,17 @@ export type ReadDocument = | UnreadableDocument | ({ kind: "read" } & InspectManifestResult); +export type ReadOptions = { + /** + * The network the reader should mean, when a person has said which. + * + * Two of the checks compare against the network's own asset, and the two Liquid networks + * carry different ones. Nothing in a document decides this, so absent an answer the reader + * is given no asset and reports those checks as not run. + */ + network?: LiquidNetwork; +}; + /** * Turns whatever is in the textarea into one of three outcomes. * @@ -19,7 +32,7 @@ export type ReadDocument = * only the first is a syntax problem. The second is the package's own judgement and is * carried through unchanged rather than restated here. */ -export function readDocument(text: string): ReadDocument { +export function readDocument(text: string, options: ReadOptions = {}): ReadDocument { const trimmed = text.trim(); if (trimmed === "") { @@ -37,8 +50,11 @@ export function readDocument(text: string): ReadDocument { }; } - // No compiler version and no policy asset, deliberately. This page ships neither, and a - // stand-in for either would turn "not checked" into "checked and fine" — the package - // reports both as skipped, and the page prints that. - return { kind: "read", ...inspectManifestDocument(parsed) }; + // The asset only when a person has named a network, and no compiler version at all yet. + // Never a stand-in for either: that would turn "not checked" into "checked and fine", and + // the package reports an absent input as skipped so the page can print it. + return { + kind: "read", + ...inspectManifestDocument(parsed, { policyAsset: options.network?.policyAsset }), + }; } diff --git a/apps/web/src/lib/liquid-networks.ts b/apps/web/src/lib/liquid-networks.ts new file mode 100644 index 0000000..2b60127 --- /dev/null +++ b/apps/web/src/lib/liquid-networks.ts @@ -0,0 +1,40 @@ +import { LIQUID_MAINNET_CHAIN_ID, LIQUID_TESTNET_CHAIN_ID } from "@humid/appkit-injected-adapter"; + +/** + * A Liquid network and the one asset this wallet moves on it. + * + * Two networks carry two different assets, and a protocol document naming `liquid` says which + * family it is for rather than which network — so nothing in a document decides this, and + * anything comparing an asset against "the network's own" has to be told which network first. + */ +export type LiquidNetwork = { + chainId: string; + name: string; + /** + * The bare 32-byte hex asset id. + * + * This is the spelling a manifest uses and the one the wallet passes when it decides whether + * to build an action. The dapp's own cards want it chain-qualified for the wallet RPC, which + * is a longer spelling of the same fact and is built from this rather than written twice. + */ + policyAsset: string; +}; + +export const LIQUID_MAINNET: LiquidNetwork = { + chainId: LIQUID_MAINNET_CHAIN_ID, + name: "Liquid", + policyAsset: "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d", +}; + +export const LIQUID_TESTNET: LiquidNetwork = { + chainId: LIQUID_TESTNET_CHAIN_ID, + name: "Liquid Testnet", + policyAsset: "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", +}; + +export const LIQUID_NETWORKS: readonly LiquidNetwork[] = [LIQUID_MAINNET, LIQUID_TESTNET]; + +/** The network with this chain id, or nothing — an unknown chain is not a network to default. */ +export function liquidNetworkByChainId(chainId: string): LiquidNetwork | undefined { + return LIQUID_NETWORKS.find((network) => network.chainId === chainId); +} From 272adca94c14b0603986e6f9426ae7141fe1b3bf Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 14 Aug 2026 14:40:53 +0300 Subject: [PATCH 121/124] Read the contracts, or say they went unread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compiler version is declared twice: by the document, and by a directive inside each contract source. The inspector held neither, so the check was reported as not run. Handing it only the version would have been worse — the check would have read one of the two places and reported as done, which is the failure this page exists to prevent, one level down. So the reader gained a third answer. A check that read one of the places that decide it is neither skipped nor passed, and it now names what it did not read. Whether it is in that state is a fact about the document rather than about the caller: a document referencing no contracts is answered by the version alone. The page supplies the version, from the same place the wallet reads it. It takes contract sources as files, read in the browser like the document in the textarea, and matches them onto the paths the document references them by. A file it cannot place reaches the reader under no path at all. The wallet's own refusal path is untouched. It always holds both inputs, and still requires both. --- apps/web/package.json | 1 + .../components/ContractSourceList.tsx | 88 +++++++++++++++++++ .../manifest/components/RefusalPanel.test.tsx | 33 ++++++- .../app/manifest/components/RefusalPanel.tsx | 30 ++++++- .../src/app/manifest/contractSources.test.ts | 67 ++++++++++++++ apps/web/src/app/manifest/contractSources.ts | 48 ++++++++++ apps/web/src/app/manifest/index.test.tsx | 1 + apps/web/src/app/manifest/index.tsx | 33 ++++++- .../web/src/app/manifest/readDocument.test.ts | 70 +++++++++++++-- apps/web/src/app/manifest/readDocument.ts | 23 ++++- bun.lock | 1 + .../tx-manifest/src/document/inspect.test.ts | 44 ++++++++++ packages/tx-manifest/src/document/inspect.ts | 22 ++++- packages/tx-manifest/src/document/refuse.ts | 31 ++++++- packages/tx-manifest/src/document/sites.ts | 23 ++++- packages/tx-manifest/src/index.ts | 1 + 16 files changed, 500 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/app/manifest/components/ContractSourceList.tsx create mode 100644 apps/web/src/app/manifest/contractSources.test.ts create mode 100644 apps/web/src/app/manifest/contractSources.ts diff --git a/apps/web/package.json b/apps/web/package.json index 4700a77..34ee935 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@humid/appkit-injected-adapter": "workspace:*", + "@humid/smplx-compiler": "workspace:*", "@humid/tx-manifest": "workspace:*", "@reown/appkit": "^1.8.19", "@reown/appkit-common": "^1.8.19", diff --git a/apps/web/src/app/manifest/components/ContractSourceList.tsx b/apps/web/src/app/manifest/components/ContractSourceList.tsx new file mode 100644 index 0000000..112eb5c --- /dev/null +++ b/apps/web/src/app/manifest/components/ContractSourceList.tsx @@ -0,0 +1,88 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +import type { SuppliedSource } from "../contractSources"; + +/** + * The contracts a document references, and which of them this page has been handed. + * + * A version this wallet does not ship can be asked for in two places, and one of them is + * inside the contract source. Nothing about a document says what its contracts contain, so + * this is the only way the second half of that check can run at all — and until it does, the + * page says so rather than reporting the check as done. + * + * The files never leave the page. They are read in the browser, the same way the document in + * the textarea is, which is what lets this ask for them at all. + */ +export function ContractSourceList({ + contracts, + onClear, + onSupply, + supplied, + unmatched, +}: { + contracts: readonly string[]; + onClear: () => void; + onSupply: (sources: SuppliedSource[]) => void; + supplied: Record<string, string>; + unmatched: readonly string[]; +}) { + return ( + <div className="flex flex-col gap-3"> + <div className="flex flex-col gap-2"> + <Label htmlFor="manifest-contracts">Contract sources</Label> + <input + id="manifest-contracts" + type="file" + multiple + accept=".simf,text/plain" + className="file:border-input file:bg-background text-sm file:mr-3 file:rounded-md file:border file:px-2.5 file:py-1 file:text-sm" + onChange={async (event) => { + const chosen = [...(event.target.files ?? [])]; + + onSupply( + await Promise.all( + chosen.map(async (file) => ({ name: file.name, text: await file.text() })), + ), + ); + }} + /> + </div> + + {contracts.length === 0 ? ( + <p className="text-sm"> + This document references no contract sources, so the compiler check has only the + document's own declaration to read and has read it. + </p> + ) : ( + <ul className="flex flex-col gap-1 text-sm"> + {contracts.map((path) => ( + <li key={path} className="flex items-center gap-2"> + <Badge variant={path in supplied ? "default" : "outline"} className="font-mono"> + {path in supplied ? "read" : "not read"} + </Badge> + <code className="font-mono text-xs">{path}</code> + </li> + ))} + </ul> + )} + + {unmatched.length > 0 && ( + <p className="text-muted-foreground text-xs"> + This document references nothing by the name {unmatched.join(", ")}, so it was not given + to the reader. A source is checked under the path the document asks for it by, and nothing + else. + </p> + )} + + {Object.keys(supplied).length > 0 && ( + <div> + <Button variant="ghost" size="sm" onClick={onClear}> + Forget the sources + </Button> + </div> + )} + </div> + ); +} diff --git a/apps/web/src/app/manifest/components/RefusalPanel.test.tsx b/apps/web/src/app/manifest/components/RefusalPanel.test.tsx index 47ce01f..3a771ea 100644 --- a/apps/web/src/app/manifest/components/RefusalPanel.test.tsx +++ b/apps/web/src/app/manifest/components/RefusalPanel.test.tsx @@ -15,9 +15,10 @@ function render(inspection: Parameters<typeof RefusalPanel>[0]["inspection"]): s const NOTHING_ASKED: Pick< Parameters<typeof RefusalPanel>[0]["inspection"], - "constructs" | "skipped" | "unreachable" + "constructs" | "partial" | "skipped" | "unreachable" > = { constructs: [], + partial: [], skipped: [], unreachable: ["covenant-mismatch", "shortfall", "no-fee-rate"], }; @@ -63,6 +64,7 @@ describe("what a reader is told about refusal", () => { test("separates checks it could have made from checks nothing could", () => { const html = render({ constructs: [], + partial: [], refusal: undefined, skipped: ["foreign-compiler"], unreachable: ["shortfall"], @@ -73,6 +75,35 @@ describe("what a reader is told about refusal", () => { expect(html).toContain("Not checkable from a document at all"); }); + // AC-04. Between skipped and done there is a third answer, and the page has to carry it or + // a check that read one of its two places is read as one that passed. + test("keeps a half-answered check apart from both a skipped one and a passed one", () => { + const html = render({ + ...NOTHING_ASKED, + partial: [{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }], + refusal: undefined, + }); + + expect(html).toContain("Checked in one of the two places that decide it"); + expect(html).toContain("./p2pk.simf"); + expect(html).not.toContain("Not checked, because"); + }); + + test("says which sources went unread rather than that some did", () => { + const html = render({ + ...NOTHING_ASKED, + partial: [{ reject: "foreign-compiler", unread: ["./lending.simf", "./script_auth.simf"] }], + refusal: undefined, + }); + + expect(html).toContain("./lending.simf"); + expect(html).toContain("./script_auth.simf"); + }); + + test("says nothing about a half-answered check when every check was answered in full", () => { + expect(render({ ...NOTHING_ASKED, refusal: undefined })).not.toContain("Checked in one of"); + }); + // Found by using this page on the five published protocols: each refused on one decorative // field and read as hopeless, when the field table below said three fixable gaps. test("says how many fields would refuse, not only which one the wallet names", () => { diff --git a/apps/web/src/app/manifest/components/RefusalPanel.tsx b/apps/web/src/app/manifest/components/RefusalPanel.tsx index b593294..ae4df6c 100644 --- a/apps/web/src/app/manifest/components/RefusalPanel.tsx +++ b/apps/web/src/app/manifest/components/RefusalPanel.tsx @@ -22,7 +22,10 @@ import { Badge } from "@/components/ui/badge"; export function RefusalPanel({ inspection, }: { - inspection: Pick<ManifestInspection, "constructs" | "refusal" | "skipped" | "unreachable">; + inspection: Pick< + ManifestInspection, + "constructs" | "partial" | "refusal" | "skipped" | "unreachable" + >; }) { const wouldRefuse = inspection.constructs.filter( (report) => report.state === "unimplemented" || report.state === "unrecognised", @@ -65,6 +68,29 @@ export function RefusalPanel({ /> )} + {inspection.partial.length > 0 && ( + <section className="flex flex-col gap-2"> + <h3 className="text-sm font-medium">Checked in one of the two places that decide it</h3> + <p className="text-muted-foreground text-xs"> + A compiler version is declared twice: by the document, and by a directive inside each + contract source. The document's own declaration was checked. These sources were not + read, so what they ask for is unknown — which is not the same as agreeing. + </p> + {inspection.partial.map((check) => ( + <div key={check.reject} className="flex flex-wrap items-center gap-1"> + <Badge variant="outline" className="font-mono"> + {check.reject} + </Badge> + {check.unread.map((path) => ( + <code key={path} className="text-muted-foreground font-mono text-xs"> + {path} + </code> + ))} + </div> + ))} + </section> + )} + <Unasked heading="Not checkable from a document at all" explanations={[ @@ -89,7 +115,7 @@ function whyUnasked(skipped: readonly RejectToken[]): string[] { if (skipped.includes("foreign-compiler")) { explanations.push( - "The compiler check needs the single SimplicityHL version a wallet ships, and this page ships none.", + "The compiler check needs the single SimplicityHL version a wallet ships, and the reader was given none.", ); } diff --git a/apps/web/src/app/manifest/contractSources.test.ts b/apps/web/src/app/manifest/contractSources.test.ts new file mode 100644 index 0000000..aecdd34 --- /dev/null +++ b/apps/web/src/app/manifest/contractSources.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; + +import { matchContractSources } from "./contractSources"; + +// A document references a contract by a path relative to itself and a person hands over a file. +// What must never happen here is a file reaching the reader under a path the document did not +// ask for: the compiler check would then be answered by a source nothing in the document names. + +describe("matching supplied files onto the paths a document uses", () => { + test("puts a file under the path whose last segment is its name", () => { + const { sources } = matchContractSources( + ["./p2pk.simf"], + [{ name: "p2pk.simf", text: "fn main() {}" }], + ); + + expect(sources).toEqual({ "./p2pk.simf": "fn main() {}" }); + }); + + test("matches a path with no directory in it at all", () => { + const { sources } = matchContractSources( + ["lending.simf"], + [{ name: "lending.simf", text: "x" }], + ); + + expect(sources).toEqual({ "lending.simf": "x" }); + }); + + test("a file the document does not reference reaches the reader under no path", () => { + const { sources, unmatched } = matchContractSources( + ["./p2pk.simf"], + [{ name: "something_else.simf", text: "x" }], + ); + + expect(sources).toEqual({}); + expect(unmatched).toEqual(["something_else.simf"]); + }); + + // A name that merely appears inside another is not the same file, and treating it as one + // would answer a check with the wrong source. + test("does not match a name that is only a suffix of the real one", () => { + const { sources, unmatched } = matchContractSources( + ["./asset_auth_vault.simf"], + [{ name: "auth_vault.simf", text: "x" }], + ); + + expect(sources).toEqual({}); + expect(unmatched).toEqual(["auth_vault.simf"]); + }); + + test("takes several files at once, and reports both sides", () => { + const { sources, unmatched } = matchContractSources( + ["./lending.simf", "./script_auth.simf"], + [ + { name: "lending.simf", text: "one" }, + { name: "script_auth.simf", text: "two" }, + { name: "notes.txt", text: "three" }, + ], + ); + + expect(sources).toEqual({ "./lending.simf": "one", "./script_auth.simf": "two" }); + expect(unmatched).toEqual(["notes.txt"]); + }); + + test("nothing supplied is nothing matched, which is not an error", () => { + expect(matchContractSources(["./p2pk.simf"], [])).toEqual({ sources: {}, unmatched: [] }); + }); +}); diff --git a/apps/web/src/app/manifest/contractSources.ts b/apps/web/src/app/manifest/contractSources.ts new file mode 100644 index 0000000..0fa2a77 --- /dev/null +++ b/apps/web/src/app/manifest/contractSources.ts @@ -0,0 +1,48 @@ +/** One contract source a person handed to this page, under the name it had on their disk. */ +export type SuppliedSource = { + name: string; + text: string; +}; + +export type MatchedSources = { + /** What the reader is given: sources under the paths the document references them by. */ + sources: Record<string, string>; + /** Names that matched nothing this document references, which is worth saying rather than ignoring. */ + unmatched: string[]; +}; + +/** + * Puts supplied files under the paths the document references them by. + * + * A document references a contract by a path relative to itself — `./p2pk.simf` — and a person + * has a file, not a path. Matching on the name at the end of the path is what closes that gap + * without asking anyone to retype a path they can read on screen. + * + * It matches rather than guesses: a file the document does not reference goes to `unmatched` + * and reaches the reader under no path at all. Handing it over under an invented key would put + * a source into a check that nothing in the document asked for. + */ +export function matchContractSources( + referenced: readonly string[], + supplied: readonly SuppliedSource[], +): MatchedSources { + const sources: Record<string, string> = {}; + const unmatched: string[] = []; + + for (const file of supplied) { + const path = referenced.find((candidate) => endsWithName(candidate, file.name)); + + if (path === undefined) { + unmatched.push(file.name); + continue; + } + + sources[path] = file.text; + } + + return { sources, unmatched }; +} + +function endsWithName(path: string, name: string): boolean { + return path === name || path.endsWith(`/${name}`); +} diff --git a/apps/web/src/app/manifest/index.test.tsx b/apps/web/src/app/manifest/index.test.tsx index 2127fe7..c41439e 100644 --- a/apps/web/src/app/manifest/index.test.tsx +++ b/apps/web/src/app/manifest/index.test.tsx @@ -30,6 +30,7 @@ describe("the inspector with nothing around it", () => { expect(html).not.toContain("What the wallet would refuse"); expect(html).not.toContain("What each field is"); expect(html).not.toContain("What was rewritten"); + expect(html).not.toContain("The contracts this document references"); }); test("offers a document to start from, so the empty box is not the only way in", () => { diff --git a/apps/web/src/app/manifest/index.tsx b/apps/web/src/app/manifest/index.tsx index 76093de..8f6051c 100644 --- a/apps/web/src/app/manifest/index.tsx +++ b/apps/web/src/app/manifest/index.tsx @@ -15,8 +15,10 @@ import { Textarea } from "@/components/ui/textarea"; import { LIQUID_NETWORKS, liquidNetworkByChainId } from "@/lib/liquid-networks"; import { ConstructTable } from "./components/ConstructTable"; +import { ContractSourceList } from "./components/ContractSourceList"; import { RefusalPanel } from "./components/RefusalPanel"; import { RewriteList } from "./components/RewriteList"; +import { matchContractSources, type SuppliedSource } from "./contractSources"; import { readDocument } from "./readDocument"; /** @@ -46,8 +48,25 @@ const NO_NETWORK = "none"; export default function ManifestInspector() { const [text, setText] = useState(""); const [chosenChain, setChosenChain] = useState(""); + const [suppliedSources, setSuppliedSources] = useState<SuppliedSource[]>([]); const network = useMemo(() => liquidNetworkByChainId(chosenChain), [chosenChain]); - const document = useMemo(() => readDocument(text, { network }), [text, network]); + + // Read twice, because a file arrives under the name it has on a disk and the reader wants it + // under the path the document references it by — and only the document says what those paths + // are. The first read asks that question, which no supplied source can change the answer to, + // and the second is the one the page reports. + const { document, matched } = useMemo(() => { + const referenced = readDocument(text, { network }); + const matched = matchContractSources( + referenced.kind === "read" && referenced.ok ? referenced.contracts : [], + suppliedSources, + ); + + return { + document: readDocument(text, { contractSources: matched.sources, network }), + matched, + }; + }, [text, network, suppliedSources]); return ( <div className="mx-auto flex min-h-svh w-full max-w-4xl flex-col gap-6 p-4 md:p-6"> @@ -133,6 +152,18 @@ export default function ManifestInspector() { > <RefusalPanel inspection={document} /> </Panel> + <Panel + title="The contracts this document references" + description="Read here in the page, so the compiler check reads both places that declare a version rather than one." + > + <ContractSourceList + contracts={document.contracts} + onClear={() => setSuppliedSources([])} + onSupply={setSuppliedSources} + supplied={matched.sources} + unmatched={matched.unmatched} + /> + </Panel> <Panel title="What each field is" description="Every field this document declares, against the position it sits in." diff --git a/apps/web/src/app/manifest/readDocument.test.ts b/apps/web/src/app/manifest/readDocument.test.ts index 5fa5041..23d2940 100644 --- a/apps/web/src/app/manifest/readDocument.test.ts +++ b/apps/web/src/app/manifest/readDocument.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { SMPLX_COMPILER_VERSION } from "@humid/smplx-compiler"; import dexManifest from "@humid/tx-manifest/fixtures/current/dex.manifest.json"; import lastWillManifest from "@humid/tx-manifest/fixtures/current/last_will.manifest.json"; import lendingV2Manifest from "@humid/tx-manifest/fixtures/current/lending_v2.manifest.json"; @@ -65,9 +66,12 @@ describe("what the textarea currently holds", () => { throw new Error("expected a readable document"); } - // The page ships no compiler version and no policy asset, and reports that rather than - // supplying a stand-in — which would turn "not checked" into "checked and fine". - expect(result.skipped).toEqual(["foreign-compiler", "foreign-asset", "unbuildable-utxo-type"]); + // The compiler version this page always has, because it reads the one the wallet ships. + // The network's asset it does not, until a person says which network — and it reports + // that rather than supplying a stand-in, which would turn "not checked" into "checked + // and fine". This document references no contracts, so the compiler check is whole. + expect(result.skipped).toEqual(["foreign-asset", "unbuildable-utxo-type"]); + expect(result.partial).toEqual([]); }); }); @@ -97,8 +101,10 @@ describe("the two checks that need the network's own asset", () => { test("with a network chosen, neither is reported as not run any more", () => { const result = read(zeroconfManifest, LIQUID_MAINNET); - // The compiler check still is; this page holds no version yet. - expect(result.skipped).toEqual(["foreign-compiler"]); + // Nothing is left unrun: this document declares no covenant contracts, so the compiler + // check has only the document's own declaration to read and has read it. + expect(result.skipped).toEqual([]); + expect(result.partial).toEqual([]); }); // The three the wallet refuses on the asset they move. Named rather than counted: a document @@ -130,3 +136,57 @@ describe("the two checks that need the network's own asset", () => { expect(read(lastWillManifest, LIQUID_TESTNET).refusal).toBeUndefined(); }); }); + +// AC-04. The page holds the compiler version the wallet ships, which answers one of the two +// places a version is declared. The other is inside each contract source, and this is the page +// saying which of those it read rather than letting the half it did read stand for both. + +describe("the compiler check, which is declared in two places", () => { + function read(manifest: unknown, contractSources?: Record<string, string>) { + const result = readDocument(JSON.stringify(manifest), { contractSources }); + + if (result.kind !== "read" || !result.ok) { + throw new Error("expected a readable document"); + } + + return result; + } + + test("names the contract sources the document references, supplied or not", () => { + expect(read(lastWillManifest).contracts).toEqual(["./last_will.simf"]); + }); + + test("with no sources, says the contracts went unread rather than reporting the check run", () => { + const result = read(lastWillManifest); + + expect(result.skipped).not.toContain("foreign-compiler"); + expect(result.partial).toEqual([{ reject: "foreign-compiler", unread: ["./last_will.simf"] }]); + }); + + test("with every referenced source supplied, the check is answered in full", () => { + const result = read(lastWillManifest, { "./last_will.simf": "fn main() {}" }); + + expect(result.partial).toEqual([]); + expect(result.refusal).toBeUndefined(); + }); + + test("refuses a source asking for a version this wallet does not ship, naming the file", () => { + const result = read(lastWillManifest, { + "./last_will.simf": 'simc "9.9.9"\nfn main() {}', + }); + + expect(result.refusal?.reject).toBe("foreign-compiler"); + expect(result.refusal?.reason).toContain("./last_will.simf"); + // The version this wallet ships, which the page reads from the same place the wallet does. + expect(result.refusal?.reason).toContain(SMPLX_COMPILER_VERSION); + }); + + test("a source that asks for the version this wallet ships is not refused", () => { + const result = read(lastWillManifest, { + "./last_will.simf": `simc "${SMPLX_COMPILER_VERSION}"\nfn main() {}`, + }); + + expect(result.refusal).toBeUndefined(); + expect(result.partial).toEqual([]); + }); +}); diff --git a/apps/web/src/app/manifest/readDocument.ts b/apps/web/src/app/manifest/readDocument.ts index 38e7f3f..23622f3 100644 --- a/apps/web/src/app/manifest/readDocument.ts +++ b/apps/web/src/app/manifest/readDocument.ts @@ -1,3 +1,4 @@ +import { SMPLX_COMPILER_VERSION } from "@humid/smplx-compiler"; import { inspectManifestDocument, type InspectManifestResult } from "@humid/tx-manifest"; import type { LiquidNetwork } from "@/lib/liquid-networks"; @@ -14,6 +15,14 @@ export type ReadDocument = | ({ kind: "read" } & InspectManifestResult); export type ReadOptions = { + /** + * The sources of the contracts this document references, under those paths. + * + * A compiler version is declared twice, and one of the two declarations lives inside the + * contract source. Absent them the reader answers for the document's own declaration and + * reports which sources it did not read, which is a third answer and not a pass. + */ + contractSources?: Record<string, string>; /** * The network the reader should mean, when a person has said which. * @@ -50,11 +59,17 @@ export function readDocument(text: string, options: ReadOptions = {}): ReadDocum }; } - // The asset only when a person has named a network, and no compiler version at all yet. - // Never a stand-in for either: that would turn "not checked" into "checked and fine", and - // the package reports an absent input as skipped so the page can print it. + // The compiler version always, because this page and the wallet read it from the same place. + // The asset only when a person has named a network, and the contract sources only when they + // have handed them over. Never a stand-in for any of them: that would turn "not checked" + // into "checked and fine", and the package says what it could not reach so the page prints + // it — including the half-answer, where the version arrived and the sources did not. return { kind: "read", - ...inspectManifestDocument(parsed, { policyAsset: options.network?.policyAsset }), + ...inspectManifestDocument(parsed, { + compilerVersion: SMPLX_COMPILER_VERSION, + contractSources: options.contractSources, + policyAsset: options.network?.policyAsset, + }), }; } diff --git a/bun.lock b/bun.lock index bf275a0..0d2ba70 100644 --- a/bun.lock +++ b/bun.lock @@ -130,6 +130,7 @@ "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@humid/appkit-injected-adapter": "workspace:*", + "@humid/smplx-compiler": "workspace:*", "@humid/tx-manifest": "workspace:*", "@reown/appkit": "^1.8.19", "@reown/appkit-common": "^1.8.19", diff --git a/packages/tx-manifest/src/document/inspect.test.ts b/packages/tx-manifest/src/document/inspect.test.ts index 3029128..ae1b746 100644 --- a/packages/tx-manifest/src/document/inspect.test.ts +++ b/packages/tx-manifest/src/document/inspect.test.ts @@ -173,6 +173,50 @@ describe("what it would refuse on, and what it never asked", () => { }); }); +// A compiler version is declared twice — once by the document and once by a directive inside +// each contract source — so a reader given only the version has answered one of the two. That +// is neither skipped nor done, and reporting it as either is this surface's own failure mode +// happening one level down. +describe("a check that read one of the two places that decide it", () => { + test("names the sources it did not read, and does not call the check skipped", () => { + const result = inspect(flat, { compilerVersion: "0.4.0" }); + + expect(result.skipped).not.toContain("foreign-compiler"); + expect(result.partial).toEqual([{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }]); + }); + + test("is answered in full once every source the document references arrives", () => { + const result = inspect(flat, { + compilerVersion: "0.4.0", + contractSources: { "./p2pk.simf": "fn main() {}" }, + }); + + expect(result.partial).toEqual([]); + expect(result.skipped).not.toContain("foreign-compiler"); + }); + + test("a document referencing no contracts is answered in full by the version alone", () => { + expect(inspect({ chain: "liquid" }, { compilerVersion: "0.4.0" }).partial).toEqual([]); + }); + + test("with no version at all the check is skipped, which is not the same thing", () => { + const result = inspect(flat); + + expect(result.skipped).toContain("foreign-compiler"); + expect(result.partial).toEqual([]); + }); + + test("refuses a source asking for another compiler, naming the file it arrived under", () => { + const result = inspect(flat, { + compilerVersion: "0.4.0", + contractSources: { "./p2pk.simf": 'simc "9.9.9"\nfn main() {}' }, + }); + + expect(result.refusal?.reject).toBe("foreign-compiler"); + expect(result.refusal?.reason).toContain("./p2pk.simf"); + }); +}); + // The reason this is a package function rather than a page: the page must not be able to // reach a network, and neither must this. describe("what it does not do", () => { diff --git a/packages/tx-manifest/src/document/inspect.ts b/packages/tx-manifest/src/document/inspect.ts index 8cc05e6..01bc4bd 100644 --- a/packages/tx-manifest/src/document/inspect.ts +++ b/packages/tx-manifest/src/document/inspect.ts @@ -3,11 +3,13 @@ import { normaliseManifest, type NormalisationNote } from "./normalise"; import { DOCUMENT_ONLY_REJECTS, NEEDS_MORE_THAN_THE_DOCUMENT_REJECTS, + type PartialCheck, type Refusal, type RejectToken, refuseFromDocumentAlone, } from "./refuse"; import { type ConstructReport, describeConstructs } from "./registry"; +import { contractSourcePaths } from "./sites"; /** * What this package makes of one document, for a reader who is not a wallet. @@ -21,6 +23,22 @@ import { type ConstructReport, describeConstructs } from "./registry"; export type ManifestInspection = { /** Every construct the document declares, against what this runtime does with it. */ constructs: ConstructReport[]; + /** + * Every contract source path this document references, whether or not it was supplied. + * + * A caller holding files has no other way to learn which of them this document is asking + * for, and the answer does not depend on what it passed in — so it is reported rather than + * left to be inferred from what came back unread. + */ + contracts: string[]; + /** + * Checks that ran against only part of what declares them, with what went unread. + * + * Empty when every check the caller's inputs allowed was answered in full. A check here has + * not passed and has not been skipped: it read one of the places that decide it, which is + * worth saying out loud rather than folding into either neighbour. + */ + partial: PartialCheck[]; /** * The first refusal decided from the document alone, if any. * @@ -86,11 +104,13 @@ export function inspectManifestDocument( } const { manifest, notes } = normaliseManifest(document); - const { refusal, skipped } = refuseFromDocumentAlone(manifest, options); + const { partial, refusal, skipped } = refuseFromDocumentAlone(manifest, options); return { constructs: describeConstructs(manifest), + contracts: contractSourcePaths(manifest), ok: true, + partial, refusal, rewrites: notes, skipped, diff --git a/packages/tx-manifest/src/document/refuse.ts b/packages/tx-manifest/src/document/refuse.ts index 0cb7dd1..b974b85 100644 --- a/packages/tx-manifest/src/document/refuse.ts +++ b/packages/tx-manifest/src/document/refuse.ts @@ -2,6 +2,7 @@ import { STATIC_WITNESS } from "../evaluation/witness"; import { asArray, asRecord } from "./json"; import type { NormalisedManifest } from "./normalise"; import { loadBearing, inspectConstructs } from "./registry"; +import { contractSourcePaths } from "./sites"; /** * What a refusal is called, so that a program can tell two of them apart. @@ -131,6 +132,19 @@ export const NEEDS_MORE_THAN_THE_DOCUMENT_REJECTS = [ "built-something-else", ] as const satisfies readonly RejectToken[]; +/** + * A check that ran against less than declares it, and what went unread. + * + * Between "not checked" and "checked" there is a third answer, and leaving it out is how a + * surface comes to report a check as done when half of it never happened. Only the compiler + * check can be in this state today, because it is the only one declared in two places. + */ +export type PartialCheck = { + reject: RejectToken; + /** What the caller did not supply, named the way the document names it. */ + unread: string[]; +}; + /** * The same refusals as {@link refuseUnsupported}, for a reader who is not a wallet. * @@ -139,6 +153,12 @@ export const NEEDS_MORE_THAN_THE_DOCUMENT_REJECTS = [ * neither gets those checks skipped rather than answered — passing a stand-in would turn * "not checked" into "checked and fine", which is the one thing this must not do. * + * The compiler check has a third outcome, because it reads two places: the document's own + * declared version, and a `simc` directive inside each contract source. Given the version and + * not the sources it answers for the first and cannot answer for the second, so it reports + * which sources went unread rather than letting a half-answer stand as a whole one. A document + * referencing no contracts has nothing unread and is answered in full. + * * Deliberately not a parameter of `refuseUnsupported`: a wallet always holds both, and an * optional field on the wallet's own path is an invitation to omit one there. */ @@ -149,8 +169,9 @@ export function refuseFromDocumentAlone( contractSources?: Record<string, string>; policyAsset?: string; }, -): { refusal: Refusal | undefined; skipped: RejectToken[] } { +): { partial: PartialCheck[]; refusal: Refusal | undefined; skipped: RejectToken[] } { const skipped: RejectToken[] = []; + const partial: PartialCheck[] = []; const compiler = input.compilerVersion === undefined @@ -162,6 +183,13 @@ export function refuseFromDocumentAlone( if (input.compilerVersion === undefined) { skipped.push("foreign-compiler"); + } else { + const supplied = input.contractSources ?? {}; + const unread = contractSourcePaths(manifest).filter((path) => !(path in supplied)); + + if (unread.length > 0) { + partial.push({ reject: "foreign-compiler", unread }); + } } const asset = @@ -175,6 +203,7 @@ export function refuseFromDocumentAlone( } return { + partial, refusal: refuseForeignChain(manifest) ?? refuseUnrecognisedConstruct(manifest) ?? diff --git a/packages/tx-manifest/src/document/sites.ts b/packages/tx-manifest/src/document/sites.ts index 0ad7427..5cabd6b 100644 --- a/packages/tx-manifest/src/document/sites.ts +++ b/packages/tx-manifest/src/document/sites.ts @@ -1,5 +1,5 @@ import { asArray, asRecord } from "./json"; -import type { NormalisedAction } from "./normalise"; +import type { NormalisedAction, NormalisedManifest } from "./normalise"; /** * One place in an action where a covenant appears, and which side it is on. @@ -61,6 +61,27 @@ export function namedUtxoTypes(action: NormalisedAction): string[] { return [...new Set(covenantSites(action).map((site) => site.utxoType))]; } +/** + * Every contract source path the whole document references, through the covenants it declares. + * + * The action-scoped question — which sources does *this* action need — belongs to a request and + * is asked there. This is the document-wide one, which is what a reader holding no request has + * to ask before it can say whether it read the contracts or only the document. + */ +export function contractSourcePaths(manifest: NormalisedManifest): string[] { + const paths = new Set<string>(); + + for (const declared of Object.values(manifest.utxoTypes)) { + const source = asRecord(asRecord(declared)?.script)?.source; + + if (typeof source === "string") { + paths.add(source); + } + } + + return [...paths]; +} + function identifierOf(entry: unknown): string { const id = asRecord(entry)?.id; diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts index ac1a9bf..5d50308 100644 --- a/packages/tx-manifest/src/index.ts +++ b/packages/tx-manifest/src/index.ts @@ -54,5 +54,6 @@ export { DOCUMENT_ONLY_REFUSALS, inspectManifestDocument, } from "./document/inspect"; +export type { PartialCheck } from "./document/refuse"; export type { ConstructReport, ConstructState } from "./document/registry"; export type { NormalisationNote } from "./document/normalise"; From 8b18ee93bbf6e9c658122d0a2bda53922b1e93e7 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 20 Aug 2026 14:23:58 +0300 Subject: [PATCH 122/124] feat(tx-manifest): perform a real deployed protocol, end to end A lending protocol deployed on Liquid testnet was driven through this wallet from a third-party dapp until all seven of its actions confirmed on chain. Everything the attempt refused, refused wrongly, or could not express is fixed here. The wallet still knows nothing about lending: it reads a document, is handed the sources it names, compiles them, builds the transaction and signs it. The transaction-manifest runtime - A covenant's address is derived from the leaf the document describes, including a leaf written as a list of parts, and the address that was verified is the one that signs the spend. - The wallet resolves what the document computes for itself and no longer asks the site for a constant the document already states. - A deployment can be read by someone who did not create it, and is completed before its fields are read, so a computed field can depend on another. - A constructor is not handed the deployment it creates. - Where an input goes is the document's to say and the wallet's to build, and a contract's change comes back in a form the next action can spend. - The wallet decides what an amount hides and now checks that it did. - A running total says which asset it was short of; the asset rule stopped refusing a spelling; a value with no declared type is no longer read as what it looks like; the wallet cannot type a value it was never asked to type. - An action spending a covenant declares how high the chain is, which a timelocked spend requires. - The guard reads past an input that creates an asset. - A published record may not name a fee nobody was charged, and a schema pointer written as a comment is a comment. The extension - Receive carries both addresses, each labelled for what it is, and scrolls. - The chain height comes from the wallet's own scan rather than an endpoint that does not exist. - An output paid back to this wallet lands where this path can spend it. - The fee is a price, so it is shown without a sign. - The covenant seam says what it was told, at warn. Elsewhere - The manifest inspector opens with what this wallet would do, one row per construct, and what is unimplemented has a page of its own. - The build refuses to bundle a wasm module that is not the one last compiled. - smplx moves to the squashed covenant-spend work rebased onto upstream dev. --- .../skills/align-project-knowledge/SKILL.md | 130 --- .../agents/openai.yaml | 4 - .agents/skills/analyze-with-graphify/SKILL.md | 75 -- .../analyze-with-graphify/agents/openai.yaml | 4 - .agents/skills/api-integration/SKILL.md | 376 -------- .../references/module-pattern.md | 157 ---- .../references/query-balancing.md | 245 ----- .../references/transport-examples.md | 133 --- .agents/skills/code-quality/SKILL.md | 129 --- .agents/skills/components/SKILL.md | 412 --------- .../curate-engineering-knowledge/SKILL.md | 61 -- .../agents/openai.yaml | 4 - .../assets/engineering-concept.md | 76 -- .../engineering-writing-contract.md | 51 -- .../skills/curate-product-knowledge/SKILL.md | 89 -- .../agents/openai.yaml | 4 - .../assets/product-concept.md | 99 -- .../references/product-writing-contract.md | 87 -- .../skills/curate-project-knowledge/SKILL.md | 131 --- .../agents/openai.yaml | 4 - .../assets/area-index.md | 61 -- .../assets/decision.md | 86 -- .../references/knowledge-model.md | 247 ----- .agents/skills/error-handling/SKILL.md | 251 ----- .../skills/error-handling/agents/openai.yaml | 4 - .../references/tanstack-query.md | 195 ---- .../references/trace-preservation.md | 180 ---- .../skills/explore-project-knowledge/SKILL.md | 97 -- .../agents/openai.yaml | 4 - .../references/exploration-contract.md | 77 -- .agents/skills/file-structure/SKILL.md | 246 ----- .../skills/file-structure/agents/openai.yaml | 4 - .agents/skills/forms/SKILL.md | 249 ----- .agents/skills/forms/references/patterns.md | 172 ---- .agents/skills/implement-work-item/SKILL.md | 106 --- .../implement-work-item/agents/openai.yaml | 6 - .../references/execution-contract.md | 29 - .agents/skills/localization/SKILL.md | 500 ---------- .../localization/references/i18n-check.md | 569 ------------ .agents/skills/logging/SKILL.md | 327 ------- .agents/skills/logging/agents/openai.yaml | 4 - .../logging/references/facade-and-records.md | 368 -------- .../logging/references/platform-transports.md | 339 ------- .../references/proven-platform-patterns.md | 162 ---- .../logging/references/trace-origins.md | 308 ------- .agents/skills/manage-project-work/SKILL.md | 165 ---- .../manage-project-work/agents/openai.yaml | 4 - .../assets/bundle-review.md | 11 - .../manage-project-work/assets/capture.md | 42 - .../assets/wayfinder-map.md | 36 - .../manage-project-work/assets/work-issue.md | 70 -- .../manage-project-work/assets/work-spec.md | 193 ---- .agents/skills/native-integration/SKILL.md | 345 ------- .agents/skills/qmd/SKILL.md | 295 ------ .agents/skills/qmd/references/mcp-setup.md | 102 --- .agents/skills/routing/SKILL.md | 330 ------- .agents/skills/routing/agents/openai.yaml | 4 - .../routing/references/tanstack-router.md | 343 ------- .../setup-workflow-environment/SKILL.md | 132 --- .../agents/openai.yaml | 4 - .../references/install-contract.md | 140 --- .../skills/shape-project-direction/SKILL.md | 79 -- .../agents/openai.yaml | 6 - .../references/direction-shaping-contract.md | 59 -- .../skills/specify-project-change/SKILL.md | 115 --- .../specify-project-change/agents/openai.yaml | 6 - .../references/specification-contract.md | 29 - .agents/skills/split-project-change/SKILL.md | 67 -- .../split-project-change/agents/openai.yaml | 6 - .../references/issue-design-contract.md | 27 - .agents/skills/state-management/SKILL.md | 263 ------ .../references/ownership-and-scope.md | 165 ---- .../references/persistence-and-security.md | 391 -------- .../references/processes-and-coordination.md | 233 ----- .../references/store-patterns.md | 211 ----- .agents/skills/user-feedback/SKILL.md | 217 ----- .../skills/user-feedback/agents/openai.yaml | 4 - .../references/presentation-patterns.md | 173 ---- .../skills/verify-knowledge-quality/SKILL.md | 99 -- .../agents/openai.yaml | 4 - .../references/authority-review.md | 35 - .../references/quality-rubric.md | 102 --- .../references/reader-communication-review.md | 40 - .agents/skills/verify-project-work/SKILL.md | 154 ---- .../verify-project-work/agents/openai.yaml | 4 - .../references/completion-gate.md | 52 -- .claude/rules/evidence-first.md | 67 -- .claude/rules/execution-continuity.md | 83 -- .claude/rules/maintainer-review.md | 327 ------- .claude/rules/project-work-bundles.md | 57 -- .claude/rules/project-work-lifecycle.md | 53 -- .claude/rules/workflow-routing.md | 28 - .claude/settings.json | 37 - .../skills/align-project-knowledge/SKILL.md | 130 --- .../agents/openai.yaml | 4 - .claude/skills/analyze-with-graphify/SKILL.md | 75 -- .../analyze-with-graphify/agents/openai.yaml | 4 - .claude/skills/api-integration/SKILL.md | 376 -------- .../references/module-pattern.md | 157 ---- .../references/query-balancing.md | 245 ----- .../references/transport-examples.md | 133 --- .claude/skills/code-quality/SKILL.md | 129 --- .claude/skills/components/SKILL.md | 412 --------- .../curate-engineering-knowledge/SKILL.md | 61 -- .../agents/openai.yaml | 4 - .../assets/engineering-concept.md | 76 -- .../engineering-writing-contract.md | 51 -- .../skills/curate-product-knowledge/SKILL.md | 89 -- .../agents/openai.yaml | 4 - .../assets/product-concept.md | 99 -- .../references/product-writing-contract.md | 87 -- .../skills/curate-project-knowledge/SKILL.md | 131 --- .../agents/openai.yaml | 4 - .../assets/area-index.md | 61 -- .../assets/decision.md | 86 -- .../references/knowledge-model.md | 247 ----- .claude/skills/error-handling/SKILL.md | 251 ----- .../skills/error-handling/agents/openai.yaml | 4 - .../references/tanstack-query.md | 195 ---- .../references/trace-preservation.md | 180 ---- .../skills/explore-project-knowledge/SKILL.md | 97 -- .../agents/openai.yaml | 4 - .../references/exploration-contract.md | 77 -- .claude/skills/file-structure/SKILL.md | 246 ----- .../skills/file-structure/agents/openai.yaml | 4 - .claude/skills/forms/SKILL.md | 249 ----- .claude/skills/forms/references/patterns.md | 172 ---- .claude/skills/implement-work-item/SKILL.md | 106 --- .../implement-work-item/agents/openai.yaml | 6 - .../references/execution-contract.md | 29 - .claude/skills/localization/SKILL.md | 500 ---------- .../localization/references/i18n-check.md | 569 ------------ .claude/skills/logging/SKILL.md | 327 ------- .claude/skills/logging/agents/openai.yaml | 4 - .../logging/references/facade-and-records.md | 368 -------- .../logging/references/platform-transports.md | 339 ------- .../references/proven-platform-patterns.md | 162 ---- .../logging/references/trace-origins.md | 308 ------- .claude/skills/manage-project-work/SKILL.md | 165 ---- .../manage-project-work/agents/openai.yaml | 4 - .../assets/bundle-review.md | 11 - .../manage-project-work/assets/capture.md | 42 - .../assets/wayfinder-map.md | 36 - .../manage-project-work/assets/work-issue.md | 70 -- .../manage-project-work/assets/work-spec.md | 193 ---- .claude/skills/native-integration/SKILL.md | 345 ------- .claude/skills/qmd/SKILL.md | 295 ------ .claude/skills/qmd/references/mcp-setup.md | 102 --- .claude/skills/routing/SKILL.md | 330 ------- .claude/skills/routing/agents/openai.yaml | 4 - .../routing/references/tanstack-router.md | 343 ------- .../setup-workflow-environment/SKILL.md | 132 --- .../agents/openai.yaml | 4 - .../references/install-contract.md | 140 --- .../skills/shape-project-direction/SKILL.md | 79 -- .../agents/openai.yaml | 6 - .../references/direction-shaping-contract.md | 59 -- .../skills/specify-project-change/SKILL.md | 115 --- .../specify-project-change/agents/openai.yaml | 6 - .../references/specification-contract.md | 29 - .claude/skills/split-project-change/SKILL.md | 67 -- .../split-project-change/agents/openai.yaml | 6 - .../references/issue-design-contract.md | 27 - .claude/skills/state-management/SKILL.md | 263 ------ .../references/ownership-and-scope.md | 165 ---- .../references/persistence-and-security.md | 391 -------- .../references/processes-and-coordination.md | 233 ----- .../references/store-patterns.md | 211 ----- .claude/skills/user-feedback/SKILL.md | 217 ----- .../skills/user-feedback/agents/openai.yaml | 4 - .../references/presentation-patterns.md | 173 ---- .../skills/verify-knowledge-quality/SKILL.md | 99 -- .../agents/openai.yaml | 4 - .../references/authority-review.md | 35 - .../references/quality-rubric.md | 102 --- .../references/reader-communication-review.md | 40 - .claude/skills/verify-project-work/SKILL.md | 154 ---- .../verify-project-work/agents/openai.yaml | 4 - .../references/completion-gate.md | 52 -- .gitignore | 10 - .graphifyignore | 39 - .oxfmtrc.json | 5 - .workflow/.gitignore | 2 - .workflow/config.json | 15 - .workflow/rules/evidence-first.md | 67 -- .workflow/rules/execution-continuity.md | 83 -- .workflow/rules/maintainer-review.md | 327 ------- .workflow/rules/project-work-bundles.md | 57 -- .workflow/rules/project-work-lifecycle.md | 53 -- .workflow/rules/workflow-routing.md | 28 - .workflow/runtime/guard-background-bash.mjs | 69 -- .workflow/runtime/guard-stop.mjs | 316 ------- .workflow/runtime/idle-guard.sh | 165 ---- .workflow/state.json | 55 -- AGENTS.md | 236 ----- CLAUDE.md | 1 - PROJECT_WORKFLOW.md | 395 -------- .../adapters/lwk/createLwkWalletBackend.ts | 5 +- .../adapters/lwk/wallet/getReceiveAddress.ts | 20 + .../adapters/lwk/wallet/readChainTipHeight.ts | 35 + .../liquid/adapters/smplx/assetOrder.test.ts | 111 +++ .../adapters/smplx/blindedOutputs.test.ts | 148 +++ .../adapters/smplx/covenantLeaves.test.ts | 128 +++ .../adapters/smplx/createdDeployment.test.ts | 136 +++ .../adapters/smplx/deployedCovenant.test.ts | 101 ++ .../adapters/smplx/loadSmplxWasm.test.ts | 44 +- .../adapters/smplx/smplxWasmForTests.ts | 54 ++ .../adapters/smplx/valueWiredCovenant.test.ts | 183 ++++ .../backends/LiquidWalletBackend.ts | 17 + .../ProcessCtConfirmation.test.tsx | 43 + .../ProcessCtConfirmation.tsx | 119 ++- .../index.test.ts | 547 ++++++++++- .../processConfidentialTransaction/index.ts | 239 ++++- .../pages/Receive/components/ReceiveView.tsx | 206 ++++- .../Home/pages/Receive/index.stories.tsx | 19 + .../App/pages/Home/pages/Receive/index.tsx | 21 +- .../Home/pages/Receive/useContractIdentity.ts | 20 + .../Account/components/AccountDetailView.tsx | 3 - .../components/ContractIdentityRow.tsx | 106 --- apps/web/src/App.tsx | 16 +- apps/web/src/app/format/index.test.tsx | 88 ++ apps/web/src/app/format/index.tsx | 125 +++ apps/web/src/app/format/positions.ts | 26 + apps/web/src/app/home/index.tsx | 10 + .../components/ConstructTable.test.tsx | 83 +- .../manifest/components/ConstructTable.tsx | 115 ++- .../manifest/components/RefusalPanel.test.tsx | 140 --- .../app/manifest/components/RefusalPanel.tsx | 157 ---- .../manifest/components/RewriteList.test.tsx | 18 +- .../app/manifest/components/RewriteList.tsx | 29 +- .../app/manifest/components/Verdict.test.tsx | 230 +++++ .../src/app/manifest/components/Verdict.tsx | 194 ++++ .../manifest/components/groupByState.test.ts | 116 ++- .../app/manifest/components/groupByState.ts | 68 +- apps/web/src/app/manifest/index.test.tsx | 11 +- apps/web/src/app/manifest/index.tsx | 76 +- .../web/src/app/manifest/readDocument.test.ts | 121 ++- .../src/contexts/Web3Provider/connect.test.ts | 229 +++++ apps/web/src/contexts/Web3Provider/connect.ts | 115 +++ apps/web/src/contexts/Web3Provider/index.tsx | 72 +- package.json | 5 +- packages/tx-manifest/src/chain/bytes.ts | 13 + .../tx-manifest/src/chain/chainRead.test.ts | 38 +- packages/tx-manifest/src/chain/chainRead.ts | 35 + .../tx-manifest/src/chain/inputGuard.test.ts | 8 +- .../tx-manifest/src/chain/outputGuard.test.ts | 204 +++++ packages/tx-manifest/src/chain/outputGuard.ts | 102 +++ .../tx-manifest/src/chain/spentInputs.test.ts | 95 ++ packages/tx-manifest/src/chain/spentInputs.ts | 55 +- packages/tx-manifest/src/chain/txOut.ts | 112 ++- .../tx-manifest/src/confirmation/index.ts | 137 ++- packages/tx-manifest/src/corpus.test.ts | 176 +++- .../src/covenants/compileParams.test.ts | 196 +++- .../src/covenants/compileParams.ts | 243 +++-- .../src/covenants/completeInstance.test.ts | 183 ++++ .../tx-manifest/src/covenants/computed.ts | 18 +- .../src/covenants/contractParamTypes.test.ts | 123 +++ .../src/covenants/contractParamTypes.ts | 137 +++ .../src/covenants/covenant.test.ts | 68 +- .../tx-manifest/src/covenants/covenant.ts | 78 +- .../src/covenants/covenantHash.test.ts | 52 +- .../tx-manifest/src/covenants/covenantHash.ts | 33 +- .../src/covenants/createdInstance.test.ts | 167 ++++ .../src/covenants/declaredTypes.test.ts | 145 +++ .../src/covenants/declaredTypes.ts | 61 ++ .../src/covenants/deployedCovenant.test.ts | 177 ++++ .../tx-manifest/src/covenants/extraLeaves.ts | 61 ++ .../tx-manifest/src/covenants/instance.ts | 173 +++- .../src/covenants/paramEncoding.test.ts | 146 +++ .../src/covenants/paramEncoding.ts | 273 ++++++ .../src/covenants/valueWiredCovenant.test.ts | 254 ++++++ .../tx-manifest/src/document/asset.test.ts | 138 +++ packages/tx-manifest/src/document/asset.ts | 172 ++++ .../tx-manifest/src/document/normalise.ts | 31 + .../src/document/references.test.ts | 31 + .../tx-manifest/src/document/references.ts | 52 ++ .../tx-manifest/src/document/refuse.test.ts | 7 + packages/tx-manifest/src/document/refuse.ts | 56 +- .../tx-manifest/src/document/registry.test.ts | 14 + packages/tx-manifest/src/document/registry.ts | 204 ++++- .../src/evaluation/assetLedger.test.ts | 297 ++++++ .../tx-manifest/src/evaluation/assetLedger.ts | 291 ++++++ .../src/evaluation/blinding.test.ts | 146 ++- .../tx-manifest/src/evaluation/blinding.ts | 102 ++- .../src/evaluation/computedValue.test.ts | 62 ++ .../src/evaluation/computedValue.ts | 58 ++ .../src/evaluation/contractArithmetic.test.ts | 190 ++++ .../tx-manifest/src/evaluation/encode.test.ts | 146 ++- packages/tx-manifest/src/evaluation/encode.ts | 108 +-- .../src/evaluation/inputOrder.test.ts | 141 +++ .../tx-manifest/src/evaluation/inputOrder.ts | 128 +++ .../tx-manifest/src/evaluation/leafParts.ts | 177 ++++ .../src/evaluation/metadataParts.test.ts | 287 ++++++ .../src/evaluation/metadataParts.ts | 337 +++++++ .../tx-manifest/src/evaluation/parameters.ts | 26 + .../tx-manifest/src/evaluation/plan.test.ts | 101 ++ packages/tx-manifest/src/evaluation/plan.ts | 44 +- packages/tx-manifest/src/index.ts | 18 +- .../src/request/requirements.test.ts | 47 + .../tx-manifest/src/request/requirements.ts | 22 +- .../src/review/assetFunding.test.ts | 171 ++++ .../tx-manifest/src/review/assetFunding.ts | 143 +++ .../src/review/covenantParams.test.ts | 213 +++++ .../src/review/createdDeployment.test.ts | 186 ++++ packages/tx-manifest/src/review/index.test.ts | 280 +++++- packages/tx-manifest/src/review/index.ts | 863 ++++++++++++++---- .../tx-manifest/src/review/multiAsset.test.ts | 553 +++++++++++ .../src/review/valueWiredCovenant.test.ts | 421 +++++++++ scripts/checkSmplxWasm.ts | 47 + skills-lock.json | 80 -- smplx | 2 +- 311 files changed, 12843 insertions(+), 27274 deletions(-) delete mode 100644 .agents/skills/align-project-knowledge/SKILL.md delete mode 100644 .agents/skills/align-project-knowledge/agents/openai.yaml delete mode 100644 .agents/skills/analyze-with-graphify/SKILL.md delete mode 100644 .agents/skills/analyze-with-graphify/agents/openai.yaml delete mode 100644 .agents/skills/api-integration/SKILL.md delete mode 100644 .agents/skills/api-integration/references/module-pattern.md delete mode 100644 .agents/skills/api-integration/references/query-balancing.md delete mode 100644 .agents/skills/api-integration/references/transport-examples.md delete mode 100644 .agents/skills/code-quality/SKILL.md delete mode 100644 .agents/skills/components/SKILL.md delete mode 100644 .agents/skills/curate-engineering-knowledge/SKILL.md delete mode 100644 .agents/skills/curate-engineering-knowledge/agents/openai.yaml delete mode 100644 .agents/skills/curate-engineering-knowledge/assets/engineering-concept.md delete mode 100644 .agents/skills/curate-engineering-knowledge/references/engineering-writing-contract.md delete mode 100644 .agents/skills/curate-product-knowledge/SKILL.md delete mode 100644 .agents/skills/curate-product-knowledge/agents/openai.yaml delete mode 100644 .agents/skills/curate-product-knowledge/assets/product-concept.md delete mode 100644 .agents/skills/curate-product-knowledge/references/product-writing-contract.md delete mode 100644 .agents/skills/curate-project-knowledge/SKILL.md delete mode 100644 .agents/skills/curate-project-knowledge/agents/openai.yaml delete mode 100644 .agents/skills/curate-project-knowledge/assets/area-index.md delete mode 100644 .agents/skills/curate-project-knowledge/assets/decision.md delete mode 100644 .agents/skills/curate-project-knowledge/references/knowledge-model.md delete mode 100644 .agents/skills/error-handling/SKILL.md delete mode 100644 .agents/skills/error-handling/agents/openai.yaml delete mode 100644 .agents/skills/error-handling/references/tanstack-query.md delete mode 100644 .agents/skills/error-handling/references/trace-preservation.md delete mode 100644 .agents/skills/explore-project-knowledge/SKILL.md delete mode 100644 .agents/skills/explore-project-knowledge/agents/openai.yaml delete mode 100644 .agents/skills/explore-project-knowledge/references/exploration-contract.md delete mode 100644 .agents/skills/file-structure/SKILL.md delete mode 100644 .agents/skills/file-structure/agents/openai.yaml delete mode 100644 .agents/skills/forms/SKILL.md delete mode 100644 .agents/skills/forms/references/patterns.md delete mode 100644 .agents/skills/implement-work-item/SKILL.md delete mode 100644 .agents/skills/implement-work-item/agents/openai.yaml delete mode 100644 .agents/skills/implement-work-item/references/execution-contract.md delete mode 100644 .agents/skills/localization/SKILL.md delete mode 100644 .agents/skills/localization/references/i18n-check.md delete mode 100644 .agents/skills/logging/SKILL.md delete mode 100644 .agents/skills/logging/agents/openai.yaml delete mode 100644 .agents/skills/logging/references/facade-and-records.md delete mode 100644 .agents/skills/logging/references/platform-transports.md delete mode 100644 .agents/skills/logging/references/proven-platform-patterns.md delete mode 100644 .agents/skills/logging/references/trace-origins.md delete mode 100644 .agents/skills/manage-project-work/SKILL.md delete mode 100644 .agents/skills/manage-project-work/agents/openai.yaml delete mode 100644 .agents/skills/manage-project-work/assets/bundle-review.md delete mode 100644 .agents/skills/manage-project-work/assets/capture.md delete mode 100644 .agents/skills/manage-project-work/assets/wayfinder-map.md delete mode 100644 .agents/skills/manage-project-work/assets/work-issue.md delete mode 100644 .agents/skills/manage-project-work/assets/work-spec.md delete mode 100644 .agents/skills/native-integration/SKILL.md delete mode 100644 .agents/skills/qmd/SKILL.md delete mode 100644 .agents/skills/qmd/references/mcp-setup.md delete mode 100644 .agents/skills/routing/SKILL.md delete mode 100644 .agents/skills/routing/agents/openai.yaml delete mode 100644 .agents/skills/routing/references/tanstack-router.md delete mode 100644 .agents/skills/setup-workflow-environment/SKILL.md delete mode 100644 .agents/skills/setup-workflow-environment/agents/openai.yaml delete mode 100644 .agents/skills/setup-workflow-environment/references/install-contract.md delete mode 100644 .agents/skills/shape-project-direction/SKILL.md delete mode 100644 .agents/skills/shape-project-direction/agents/openai.yaml delete mode 100644 .agents/skills/shape-project-direction/references/direction-shaping-contract.md delete mode 100644 .agents/skills/specify-project-change/SKILL.md delete mode 100644 .agents/skills/specify-project-change/agents/openai.yaml delete mode 100644 .agents/skills/specify-project-change/references/specification-contract.md delete mode 100644 .agents/skills/split-project-change/SKILL.md delete mode 100644 .agents/skills/split-project-change/agents/openai.yaml delete mode 100644 .agents/skills/split-project-change/references/issue-design-contract.md delete mode 100644 .agents/skills/state-management/SKILL.md delete mode 100644 .agents/skills/state-management/references/ownership-and-scope.md delete mode 100644 .agents/skills/state-management/references/persistence-and-security.md delete mode 100644 .agents/skills/state-management/references/processes-and-coordination.md delete mode 100644 .agents/skills/state-management/references/store-patterns.md delete mode 100644 .agents/skills/user-feedback/SKILL.md delete mode 100644 .agents/skills/user-feedback/agents/openai.yaml delete mode 100644 .agents/skills/user-feedback/references/presentation-patterns.md delete mode 100644 .agents/skills/verify-knowledge-quality/SKILL.md delete mode 100644 .agents/skills/verify-knowledge-quality/agents/openai.yaml delete mode 100644 .agents/skills/verify-knowledge-quality/references/authority-review.md delete mode 100644 .agents/skills/verify-knowledge-quality/references/quality-rubric.md delete mode 100644 .agents/skills/verify-knowledge-quality/references/reader-communication-review.md delete mode 100644 .agents/skills/verify-project-work/SKILL.md delete mode 100644 .agents/skills/verify-project-work/agents/openai.yaml delete mode 100644 .agents/skills/verify-project-work/references/completion-gate.md delete mode 100644 .claude/rules/evidence-first.md delete mode 100644 .claude/rules/execution-continuity.md delete mode 100644 .claude/rules/maintainer-review.md delete mode 100644 .claude/rules/project-work-bundles.md delete mode 100644 .claude/rules/project-work-lifecycle.md delete mode 100644 .claude/rules/workflow-routing.md delete mode 100644 .claude/settings.json delete mode 100644 .claude/skills/align-project-knowledge/SKILL.md delete mode 100644 .claude/skills/align-project-knowledge/agents/openai.yaml delete mode 100644 .claude/skills/analyze-with-graphify/SKILL.md delete mode 100644 .claude/skills/analyze-with-graphify/agents/openai.yaml delete mode 100644 .claude/skills/api-integration/SKILL.md delete mode 100644 .claude/skills/api-integration/references/module-pattern.md delete mode 100644 .claude/skills/api-integration/references/query-balancing.md delete mode 100644 .claude/skills/api-integration/references/transport-examples.md delete mode 100644 .claude/skills/code-quality/SKILL.md delete mode 100644 .claude/skills/components/SKILL.md delete mode 100644 .claude/skills/curate-engineering-knowledge/SKILL.md delete mode 100644 .claude/skills/curate-engineering-knowledge/agents/openai.yaml delete mode 100644 .claude/skills/curate-engineering-knowledge/assets/engineering-concept.md delete mode 100644 .claude/skills/curate-engineering-knowledge/references/engineering-writing-contract.md delete mode 100644 .claude/skills/curate-product-knowledge/SKILL.md delete mode 100644 .claude/skills/curate-product-knowledge/agents/openai.yaml delete mode 100644 .claude/skills/curate-product-knowledge/assets/product-concept.md delete mode 100644 .claude/skills/curate-product-knowledge/references/product-writing-contract.md delete mode 100644 .claude/skills/curate-project-knowledge/SKILL.md delete mode 100644 .claude/skills/curate-project-knowledge/agents/openai.yaml delete mode 100644 .claude/skills/curate-project-knowledge/assets/area-index.md delete mode 100644 .claude/skills/curate-project-knowledge/assets/decision.md delete mode 100644 .claude/skills/curate-project-knowledge/references/knowledge-model.md delete mode 100644 .claude/skills/error-handling/SKILL.md delete mode 100644 .claude/skills/error-handling/agents/openai.yaml delete mode 100644 .claude/skills/error-handling/references/tanstack-query.md delete mode 100644 .claude/skills/error-handling/references/trace-preservation.md delete mode 100644 .claude/skills/explore-project-knowledge/SKILL.md delete mode 100644 .claude/skills/explore-project-knowledge/agents/openai.yaml delete mode 100644 .claude/skills/explore-project-knowledge/references/exploration-contract.md delete mode 100644 .claude/skills/file-structure/SKILL.md delete mode 100644 .claude/skills/file-structure/agents/openai.yaml delete mode 100644 .claude/skills/forms/SKILL.md delete mode 100644 .claude/skills/forms/references/patterns.md delete mode 100644 .claude/skills/implement-work-item/SKILL.md delete mode 100644 .claude/skills/implement-work-item/agents/openai.yaml delete mode 100644 .claude/skills/implement-work-item/references/execution-contract.md delete mode 100644 .claude/skills/localization/SKILL.md delete mode 100644 .claude/skills/localization/references/i18n-check.md delete mode 100644 .claude/skills/logging/SKILL.md delete mode 100644 .claude/skills/logging/agents/openai.yaml delete mode 100644 .claude/skills/logging/references/facade-and-records.md delete mode 100644 .claude/skills/logging/references/platform-transports.md delete mode 100644 .claude/skills/logging/references/proven-platform-patterns.md delete mode 100644 .claude/skills/logging/references/trace-origins.md delete mode 100644 .claude/skills/manage-project-work/SKILL.md delete mode 100644 .claude/skills/manage-project-work/agents/openai.yaml delete mode 100644 .claude/skills/manage-project-work/assets/bundle-review.md delete mode 100644 .claude/skills/manage-project-work/assets/capture.md delete mode 100644 .claude/skills/manage-project-work/assets/wayfinder-map.md delete mode 100644 .claude/skills/manage-project-work/assets/work-issue.md delete mode 100644 .claude/skills/manage-project-work/assets/work-spec.md delete mode 100644 .claude/skills/native-integration/SKILL.md delete mode 100644 .claude/skills/qmd/SKILL.md delete mode 100644 .claude/skills/qmd/references/mcp-setup.md delete mode 100644 .claude/skills/routing/SKILL.md delete mode 100644 .claude/skills/routing/agents/openai.yaml delete mode 100644 .claude/skills/routing/references/tanstack-router.md delete mode 100644 .claude/skills/setup-workflow-environment/SKILL.md delete mode 100644 .claude/skills/setup-workflow-environment/agents/openai.yaml delete mode 100644 .claude/skills/setup-workflow-environment/references/install-contract.md delete mode 100644 .claude/skills/shape-project-direction/SKILL.md delete mode 100644 .claude/skills/shape-project-direction/agents/openai.yaml delete mode 100644 .claude/skills/shape-project-direction/references/direction-shaping-contract.md delete mode 100644 .claude/skills/specify-project-change/SKILL.md delete mode 100644 .claude/skills/specify-project-change/agents/openai.yaml delete mode 100644 .claude/skills/specify-project-change/references/specification-contract.md delete mode 100644 .claude/skills/split-project-change/SKILL.md delete mode 100644 .claude/skills/split-project-change/agents/openai.yaml delete mode 100644 .claude/skills/split-project-change/references/issue-design-contract.md delete mode 100644 .claude/skills/state-management/SKILL.md delete mode 100644 .claude/skills/state-management/references/ownership-and-scope.md delete mode 100644 .claude/skills/state-management/references/persistence-and-security.md delete mode 100644 .claude/skills/state-management/references/processes-and-coordination.md delete mode 100644 .claude/skills/state-management/references/store-patterns.md delete mode 100644 .claude/skills/user-feedback/SKILL.md delete mode 100644 .claude/skills/user-feedback/agents/openai.yaml delete mode 100644 .claude/skills/user-feedback/references/presentation-patterns.md delete mode 100644 .claude/skills/verify-knowledge-quality/SKILL.md delete mode 100644 .claude/skills/verify-knowledge-quality/agents/openai.yaml delete mode 100644 .claude/skills/verify-knowledge-quality/references/authority-review.md delete mode 100644 .claude/skills/verify-knowledge-quality/references/quality-rubric.md delete mode 100644 .claude/skills/verify-knowledge-quality/references/reader-communication-review.md delete mode 100644 .claude/skills/verify-project-work/SKILL.md delete mode 100644 .claude/skills/verify-project-work/agents/openai.yaml delete mode 100644 .claude/skills/verify-project-work/references/completion-gate.md delete mode 100644 .graphifyignore delete mode 100644 .workflow/.gitignore delete mode 100644 .workflow/config.json delete mode 100644 .workflow/rules/evidence-first.md delete mode 100644 .workflow/rules/execution-continuity.md delete mode 100644 .workflow/rules/maintainer-review.md delete mode 100644 .workflow/rules/project-work-bundles.md delete mode 100644 .workflow/rules/project-work-lifecycle.md delete mode 100644 .workflow/rules/workflow-routing.md delete mode 100644 .workflow/runtime/guard-background-bash.mjs delete mode 100644 .workflow/runtime/guard-stop.mjs delete mode 100644 .workflow/runtime/idle-guard.sh delete mode 100644 .workflow/state.json delete mode 100644 AGENTS.md delete mode 120000 CLAUDE.md delete mode 100644 PROJECT_WORKFLOW.md create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readChainTipHeight.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/assetOrder.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/blindedOutputs.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/covenantLeaves.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/createdDeployment.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/deployedCovenant.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/valueWiredCovenant.test.ts create mode 100644 apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts delete mode 100644 apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx create mode 100644 apps/web/src/app/format/index.test.tsx create mode 100644 apps/web/src/app/format/index.tsx create mode 100644 apps/web/src/app/format/positions.ts delete mode 100644 apps/web/src/app/manifest/components/RefusalPanel.test.tsx delete mode 100644 apps/web/src/app/manifest/components/RefusalPanel.tsx create mode 100644 apps/web/src/app/manifest/components/Verdict.test.tsx create mode 100644 apps/web/src/app/manifest/components/Verdict.tsx create mode 100644 apps/web/src/contexts/Web3Provider/connect.test.ts create mode 100644 apps/web/src/contexts/Web3Provider/connect.ts create mode 100644 packages/tx-manifest/src/chain/outputGuard.test.ts create mode 100644 packages/tx-manifest/src/chain/outputGuard.ts create mode 100644 packages/tx-manifest/src/chain/spentInputs.test.ts create mode 100644 packages/tx-manifest/src/covenants/completeInstance.test.ts create mode 100644 packages/tx-manifest/src/covenants/contractParamTypes.test.ts create mode 100644 packages/tx-manifest/src/covenants/contractParamTypes.ts create mode 100644 packages/tx-manifest/src/covenants/createdInstance.test.ts create mode 100644 packages/tx-manifest/src/covenants/declaredTypes.test.ts create mode 100644 packages/tx-manifest/src/covenants/declaredTypes.ts create mode 100644 packages/tx-manifest/src/covenants/deployedCovenant.test.ts create mode 100644 packages/tx-manifest/src/covenants/extraLeaves.ts create mode 100644 packages/tx-manifest/src/covenants/paramEncoding.test.ts create mode 100644 packages/tx-manifest/src/covenants/paramEncoding.ts create mode 100644 packages/tx-manifest/src/covenants/valueWiredCovenant.test.ts create mode 100644 packages/tx-manifest/src/document/asset.test.ts create mode 100644 packages/tx-manifest/src/document/asset.ts create mode 100644 packages/tx-manifest/src/evaluation/assetLedger.test.ts create mode 100644 packages/tx-manifest/src/evaluation/assetLedger.ts create mode 100644 packages/tx-manifest/src/evaluation/computedValue.test.ts create mode 100644 packages/tx-manifest/src/evaluation/computedValue.ts create mode 100644 packages/tx-manifest/src/evaluation/contractArithmetic.test.ts create mode 100644 packages/tx-manifest/src/evaluation/inputOrder.test.ts create mode 100644 packages/tx-manifest/src/evaluation/inputOrder.ts create mode 100644 packages/tx-manifest/src/evaluation/leafParts.ts create mode 100644 packages/tx-manifest/src/evaluation/metadataParts.test.ts create mode 100644 packages/tx-manifest/src/evaluation/metadataParts.ts create mode 100644 packages/tx-manifest/src/review/assetFunding.test.ts create mode 100644 packages/tx-manifest/src/review/assetFunding.ts create mode 100644 packages/tx-manifest/src/review/covenantParams.test.ts create mode 100644 packages/tx-manifest/src/review/createdDeployment.test.ts create mode 100644 packages/tx-manifest/src/review/multiAsset.test.ts create mode 100644 packages/tx-manifest/src/review/valueWiredCovenant.test.ts create mode 100644 scripts/checkSmplxWasm.ts delete mode 100644 skills-lock.json diff --git a/.agents/skills/align-project-knowledge/SKILL.md b/.agents/skills/align-project-knowledge/SKILL.md deleted file mode 100644 index ce4382a..0000000 --- a/.agents/skills/align-project-knowledge/SKILL.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -name: align-project-knowledge -description: Align a significant task's shaping spec with current project purpose, Areas, capabilities, flows, architecture, decisions, repository responsibilities, and known uncertainties. Use after the shaping record exists and before choosing a design, approving framing, changing a contract or flow, or making assumptions about why the project behaves as it does. ---- - -# Align Project Knowledge - -Do not design from code and memory alone. Establish the project's current intent before proposing a solution. - -## Procedure - -1. Read `.workflow/config.json` and resolve the configured knowledge repository. -2. Inspect the current session skill catalog and require the official native - `qmd` skill. Invoke it before retrieval. If it is absent, stop and ask to - invoke `setup-workflow-environment` to repair or reinstall the selected - project/user skills, then ask only for the unavoidable agent-session - restart. An on-disk file alone does not prove the running agent loaded it. -3. Run QMD from that knowledge root. Require `qmd status`; if it reports - documents pending embedding, run `qmd embed` before relying on vector or - hybrid retrieval — indexing and embedding are separate, `qmd update` only - marks what needs vectors, and searching without them silently degrades to - lexical BM25 over exactly the material most recently written. If QMD or the - project-local `.qmd/index.yml` is missing, stop and report the broken - workflow environment. -4. Run `wfctl knowledge build --target <knowledge-root>`. Stop alignment if - validation, knowledge-graph compilation, or claim-ledger compilation fails; - do not silently reason over broken navigation. The generated - `.workflow/current/knowledge-graph.json` is disposable knowledge navigation. - `.workflow/current/claim-ledger.json` is disposable explicit candidate - lineage. Neither is authority or an edit target. -5. Start at `knowledge/index.md`, then use `qmd search ... -c knowledge` for - exact terms or a structured `qmd query` with authored `intent:`, `lex:`, - `vec:`, and when useful `hyde:` fields for hybrid retrieval. - If QMD MCP is available, use `query` with - `collections: ["knowledge"]`. Open the returned concepts directly and use - directory indexes for progressive disclosure. Retrieval ranking is not - authority. -6. Expand the QMD candidates through explicit incoming and outgoing edges in - the compiled graph. Follow material typed relationships, Area ownership, - decision lineage, and human-authored links so lexical similarity does not - define the task boundary. -7. Open only concepts relevant to the work, including: - - vision and non-goals, - - the relevant Area index, capabilities, concepts, rules, and flows, - - architectural boundaries, - - current and superseded decisions, - - repository responsibilities, - - recorded uncertainties. -8. Inspect `status`, `generated`, `verified.content_hash`, `stale_after`, and - `sources` before treating a concept as authoritative. `wfctl knowledge - validate` must prove that at least one verification matches the current - material content. -9. Follow links to predecessor decisions and supporting sources when the proposed work depends on them. -10. Compare the proposed behavior with both code evidence and curated intent. -11. Treat only `knowledge/` as the default current-knowledge surface. Do not - consult `raw/` or `intake/` to fill a gap. -12. Ask whether this was already decided: `wfctl knowledge decided "<subject>" - --record <id>`. It reads the four places an answer lands — a promoted - decision page, the bundle that asked the question, a resolved Wayfinder map, - a capture — plus issues already delivered, and writes what it found into this - bundle's alignment, which the framing gate requires. Cite the promoted page - when there is one and the record when there is not, and say which. Search - `changes` with QMD as well when the wording is uncertain: the command ranks - by overlapping terms and finds what shares them, where a semantic query finds - what means the same thing. -13. Record QMD queries, graph-expanded concept paths, directly reviewed - concept paths, constraints, and any conflict in the central change bundle. - -When a bundle already exists, run `wfctl work status <id>` and stage-specific -`wfctl work context <id>` first. Read curated knowledge from its `Knowledge -root`, update only the returned bundle files, and inspect implementation only -from exact `Code roots`. Do not treat the knowledge repository as the -implementation checkout. - -## When there is no baseline yet - -An existing project installed into this workflow starts with an empty or barely -populated `knowledge/`, and that is a supported state, not an error. A -reconstruction is expensive enough that nobody runs one before their first fix, -so most first tasks in a real repository run without one. - -Report absence rather than a clean result. "No conflicts with curated knowledge" -is literally true against an empty corpus and tells the reader nothing, while -reading exactly like a completed check. Record instead that no curated concept -covers this work, that the contract is therefore unaligned by absence rather -than by verification, and what the alignment rested on instead — pinned source, -tests, maintainer statements. The same applies to a populated corpus that simply -has nothing about this Area: coverage is per-subject, not per-repository. - -Recommend a reconstruction when the gap is material and say what it would -establish, then proceed if the maintainer declines. It is a recommendation, and -it never becomes a precondition for doing the work. - -## Recorded drift is work nobody has claimed - -`realization.alignment: drifted` on a curated page means the project accepted an -intent its implementation does not deliver. That row is a faithful record and -nothing more: reconstruction never edits source, so the gap it names outlives -the case that found it. `wfctl brief` reports `corpus.intent-delivery-drift` -with the pages by name, and a body of debt that only ever appears there is the -same as no record at all. - -Read those pages whenever work touches their Area, and treat each one as a -candidate the current task either resolves, widens, or leaves untouched. Say -which, in the alignment record, so the next reader knows the drift was seen -rather than missed. - -Drift becomes work through the ordinary route and never by direct promotion. -Group the drifted pages by the outcome that would close them — several rows are -usually one initiative — and put that outcome to the maintainer as one decision -with three honest answers: shape it now, accept the gap and record the intent as -superseded so the drift disappears truthfully, or defer it with a reason. Only -the first creates a bundle, through `shape-project-direction` when the route is -foggy and `specify-project-change` when it is not. - -Never resolve drift by editing the page to match the code. That erases an -accepted intent to make a check pass, and the record of what the project meant -is the only thing that made the gap visible. - -## Conflicts - -- `raw/` is neither evidence nor current truth. It is an untrusted clue source - used only through `process-raw-intake`. -- A later timestamp does not automatically make a source authoritative. -- `status: stable` is valid only with a matching current content hash; normative - claims additionally require human verification. -- A timestamp without a matching content hash does not prove the current text - was reviewed. -- When sources or code disagree and the correct intent cannot be established, ask the maintainer. -- Preserve unresolved uncertainty explicitly. Do not create a spec that silently selects one interpretation. diff --git a/.agents/skills/align-project-knowledge/agents/openai.yaml b/.agents/skills/align-project-knowledge/agents/openai.yaml deleted file mode 100644 index df0a442..0000000 --- a/.agents/skills/align-project-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Align Project Knowledge" - short_description: "Align shaping with Areas and current truth" - default_prompt: "Use $align-project-knowledge to align this shaping record with relevant Areas, decisions, architecture, and current curated truth." diff --git a/.agents/skills/analyze-with-graphify/SKILL.md b/.agents/skills/analyze-with-graphify/SKILL.md deleted file mode 100644 index c728404..0000000 --- a/.agents/skills/analyze-with-graphify/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: analyze-with-graphify -description: Mandatory Graphify routing gate for every source-code-dependent task. Use before locating, reading, explaining, planning, changing, debugging, reviewing, or verifying code; tracing callers, dependencies, data or control flow; estimating impact; or checking an implementation claim against a repository, even when the user does not mention Graphify. Check the current session for the official native graphify skill and any more specific Graphify skills, invoke them first, then inspect the actual source. Do not use Graphify as the primary parser, search engine, or authority for raw Markdown or curated knowledge files. ---- - -# Require Graphify First - -This skill owns mandatory workflow routing. The official native `graphify` -skill supplied by Graphify owns tool-specific execution. - -## Mandatory trigger - -Invoke this skill before any source-code-dependent understanding or change, -including implementation, investigation, planning, review, verification, -debugging, navigation, impact analysis, architecture work, and verification of -knowledge claims against code. Do not wait for the maintainer to mention -Graphify. - -Do not invoke it merely to read or reconcile Markdown, specs, raw intake, -curated concepts, prose documentation, or workflow configuration. Those use -QMD retrieval plus direct reading and their native structure. Invoke Graphify -when that work crosses the boundary into a source repository. - -## Session skill gate - -1. Inspect the skills exposed in the current session before source-code - navigation. -2. Require the official native skill named `graphify`, or a provider-namespaced - equivalent whose metadata identifies the official Graphify skill. Do not - count this `analyze-with-graphify` routing skill as the native skill. -3. Identify any additional Graphify-specific skills in the session and invoke - the most specific relevant one after loading the native skill. -4. Treat the current session catalog as authoritative. A skill file present on - disk may require an agent restart before it becomes active. -5. Invoke the native `graphify` skill and follow its complete procedure before - continuing the project task. - -If the native skill is absent, stop repository analysis: - -- If the `graphify` CLI is also absent, offer: - - ```sh - uv tool install graphifyy - graphify install --platform <agent> - ``` - -- If the CLI exists, offer only the matching - `graphify install --platform <agent>` command. Use `graphify install --help` - to resolve the current platform name instead of guessing it. -- Tell the maintainer to restart the agent session after installation. -- Do not install user-level tooling without authority and do not silently fall - back to a partial hand-written Graphify procedure. - -## Project obligations - -1. Use the native skill to query an existing `graphify-out/graph.json` - immediately or build/update the graph when required. -2. Trace source-code relationships through the graph before drawing - conclusions. -3. Open and inspect the actual source locations returned by Graphify. The - source at the recorded Git revision, not the graph, is implementation - authority. -4. Only then use `rg` or equivalent text search for exact tokens, literals, - generated artifacts, or gaps not represented in the graph. -5. Record relevant queries, paths, and source locations in the active spec or - curation record. - -## Honesty - -- Distinguish extracted edges from inferred or ambiguous edges. -- Do not turn a missing graph result into proof that code does not exist. -- State when the graph is stale and update it before relying on changed sources. -- Do not substitute grep output for relationship analysis. -- Do not cite `graphify-out/` as proof in curated knowledge. Cite pinned source - locations and fresh checks reached through Graphify. diff --git a/.agents/skills/analyze-with-graphify/agents/openai.yaml b/.agents/skills/analyze-with-graphify/agents/openai.yaml deleted file mode 100644 index c3ec6d8..0000000 --- a/.agents/skills/analyze-with-graphify/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Require Graphify First" - short_description: "Mandatory Graphify gate for source code" - default_prompt: "Use $analyze-with-graphify before any source-code-dependent analysis or change, confirm the native Graphify skills are active, invoke them first, and verify results in the actual source." diff --git a/.agents/skills/api-integration/SKILL.md b/.agents/skills/api-integration/SKILL.md deleted file mode 100644 index b619cbf..0000000 --- a/.agents/skills/api-integration/SKILL.md +++ /dev/null @@ -1,376 +0,0 @@ ---- -name: api-integration -description: Use whenever client code integrates with any external data source or API, including REST, GraphQL, RPC, IPC or native bridges, SDKs, gateways, microservices, WebSockets, generated contracts, and mock or fixture data used to scaffold application data. Apply when creating or changing API clients, transport or auth middleware, api/modules boundaries, contract generation or inferred types, request functions, query keys, query or mutation registries, pagination, query balancing with TanStack Pacer, batching, cache updates or invalidation, and components or hooks that consume API data. ---- - -# API Integration - -Treat `api/` as the client application's boundary with external sources. Keep -transport details, external contracts, server-state identities, and cache -effects behind that boundary so feature code consumes a stable, typed module -instead of speaking directly to a transport. - -The architecture is independent of the transport: - -```text -external contract authority - -> generated or inferred contract - -> typed source client - -> source module operations - -> key registry - -> query and mutation registries - -> application consumers -``` - -OpenAPI, GraphQL code generation, a schema registry, a typed SDK, and a native -RPC bridge are different ways to supply the first three links. They do not -change the remaining shape. - -## Start by finding the real source - -Before editing: - -1. Identify every external source touched by the change. -2. Find the authoritative contract for each source. -3. Find how its client is constructed and where cross-cutting behavior lives. -4. Trace the existing key, query, mutation, and invalidation paths. -5. Check how generated artifacts are produced and validated. - -Do not invent a second integration path because a call is small. Extend the -source module that already owns the contract and transport. - -## Place integrations under `api/` - -Prefer this application boundary: - -```text -api/ - client.ts - provider.tsx - modules/ - auth/ - service-a/ - service-b/ -``` - -- Put client-side integrations with external sources under `api/`. -- When the application has multiple sources, give each source its own - `api/modules/<source>/` directory. -- A source may be a separately governed service, a gateway-backed service - surface, a third-party SDK, or a native capability. The network address is - not the boundary: multiple services behind one gateway remain separate - modules when their contracts and ownership are separate. -- Name modules after the external source or contract authority, not after a - screen, component, or user journey. -- Split a large source module internally by operation or resource when useful, - but keep one public module surface. -- Keep source-neutral infrastructure such as the shared cache client and its - provider at the `api/` root. - -Feature components must not import generated transports, raw SDKs, RPC clients, -or wire contracts directly. They consume the source module. - -## Derive types; do not curate DTOs - -Use contract types in this order: - -1. Types generated from the authoritative external contract. -2. Types inferred from runtime schemas or a typed registry. -3. Request and response types exposed by a typed SDK or RPC client. -4. Narrow aliases or projections derived from one of the above. -5. Hand-written boundary types only when no machine-readable or typed contract - exists. - -Never duplicate an external DTO by manually restating its fields. Regenerate or -re-infer it. - -Do not create a hand-maintained `types.ts` dumping ground. A `types.ts` file -written by a generator is a generated artifact, not a dumping ground: mark it -as generated, never edit it manually, and make the generator its source of -truth. Genuine client-only models should be: - -- inferred from a runtime schema when validation is required; -- colocated with the operation that owns them; -- named after their responsibility rather than placed in a generic type bag; -- kept outside the API contract when they are view, form, or component state. - -An alias is useful when it creates a stable public name for an unreadable -generated lookup. An alias that merely duplicates another local alias is not. - -## Keep mock data as scaffolding - -Treat mock data as temporary display scaffolding, not as a half-built domain -layer. Keep mock-only data behind one obvious nearest boundary: prefer the -project's established API mocking or fixture boundary; otherwise colocate it -with the source module or consumer that owns it. - -- Store literal display text and values directly in the fixture. -- Reuse real generated, inferred, SDK, or package contracts when the represented - records already exist. -- Do not invent exported domain types, fake service methods, resolvers, - registries, adapters, or parser-style utilities for functionality that does - not exist. -- Do not normalize or transform static fixture data merely to imitate a future - production integration. -- Remove or replace the mock boundary when the real source becomes available; - do not let both become competing authorities. - -## Make generation reproducible - -When contract generation is available: - -- keep the upstream contract or a deterministic contract-sync command; -- map one independently governed source to one module output; -- generate into a predictable, reviewable location; -- include a generated-file header; -- fail when required contract inputs are missing; -- detect stale outputs after sources are renamed or removed; -- expose one documented command that can be rerun locally and in CI; -- validate that regeneration leaves the worktree unchanged. - -Do not edit generated output to make a consumer compile. Fix the authority, -generator, or adapter. - -## Construct clients at the boundary - -Put shared transport concerns in an API client factory or a source-specific -client: - -- base URL or channel selection; -- authentication and refresh coordination; -- protocol-required request and response payload encoding; -- retry and timeout policy; -- transport-level logging; -- middleware or interceptors. - -Create a dedicated client when a source needs different middleware, such as an -authentication endpoint that must not invoke its own refresh interceptor. - -Do not put query keys, cache invalidation, navigation, toasts, or component -state in the transport client. The client moves typed messages; the source -module owns server-state semantics. - -## Give every cached source a stable public module surface - -For a TanStack Query integration, prefer this top-to-bottom order: - -1. Intentional public aliases derived from the contract, if needed. -2. The typed source client. -3. Plain transport operations, when reuse or focused testing warrants them. -4. `<source>Keys`. -5. `<source>Queries`. -6. `<source>Mutations`. -7. Source-local helpers. - -The registries are plain values and factories outside React. Components choose -`useQuery`, `useSuspenseQuery`, `useMutation`, prefetching, or route loading; -the API module supplies the canonical options. - -If the repository uses another server-state library, preserve the same -separation: stable identities, reusable read definitions, reusable write -definitions, and explicit post-write cache effects. - -## Build a hierarchical key registry - -Export one key registry per source module. - -- Start with an immutable `all` namespace key. -- Derive narrower keys by spreading their parent key. -- Include every argument that changes the response. -- Put stable labels before dynamic values. -- Keep key values serializable and deterministic. -- Use the same registry for queries, mutations, prefetching, cache writes, and - invalidation. -- Design useful prefixes for broad invalidation, list invalidation, and exact - entity invalidation. - -There is no arbitrary maximum key depth. The hierarchy should describe cache -identity and invalidation boundaries, not satisfy a visual limit. - -```ts -export const accountsKeys = { - all: ["accounts"] as const, - lists: () => [...accountsKeys.all, "list"] as const, - list: (filters: AccountFilters) => [...accountsKeys.lists(), filters] as const, - details: () => [...accountsKeys.all, "detail"] as const, - detail: (id: string) => [...accountsKeys.details(), id] as const, - mutations: () => [...accountsKeys.all, "mutation"] as const, -}; -``` - -## Export query definitions as a registry - -Query factories own the canonical relationship between a key and its fetch: - -```ts -export const accountsQueries = { - list: (filters: AccountFilters) => - queryOptions({ - queryKey: accountsKeys.list(filters), - queryFn: () => listAccounts(filters), - }), - detail: (id: string) => - queryOptions({ - queryKey: accountsKeys.detail(id), - enabled: Boolean(id), - queryFn: () => getAccount(id), - }), -}; -``` - -- Use `queryOptions` or the repository's equivalent so consumers share one - definition. -- Gate a query when required identity is unavailable. -- Keep UI effects out of query functions. -- Return contract data or an intentional boundary normalization, not a - component-shaped view model. -- For pagination, make the cursor or offset part of the query definition and - implement a real exhaustion condition. - -Plain transport functions are optional. Keep them when multiple query -definitions reuse an operation, non-React code calls it, or focused tests need -the transport boundary. Otherwise a typed client call may live directly in the -query function. - -## Export mutation definitions and declare cache effects - -Define reusable mutation option factories outside React: - -```ts -export const accountsMutations = { - rename: () => - mutationOptions({ - mutationKey: [...accountsKeys.mutations(), "rename"] as const, - mutationFn: renameAccount, - onSettled: (_data, _error, variables) => { - void queryClient.invalidateQueries({ queryKey: accountsKeys.lists() }); - void queryClient.invalidateQueries({ - queryKey: accountsKeys.detail(variables.id), - }); - }, - }), -}; -``` - -Every write must make its cache effects explicit: - -- invalidate the exact entity when only that entity can change; -- invalidate a list prefix when membership or ordering can change; -- invalidate the source root only when the effect is genuinely broad; -- invalidate other source modules when the write changes their data; -- update cache directly only when the returned contract data is sufficient to - do so without guessing; -- state intentionally when a mutation has no cached read effect. - -Prefer `onSettled` when the server may have changed state despite an error or -when this is the repository convention. Use `onSuccess` when the protocol -guarantees no state change on failure and avoiding a failed-write refetch is -important. Correctness determines the callback, not habit. - -Consumers own UI effects such as navigation, notifications, dialog state, and -form reset. They must not replace the module's cache lifecycle accidentally. -Prefer per-call callbacks or explicitly compose handlers when adding local -behavior. - -## Preserve transport errors - -Propagate the transport's original error value unchanged by default. Prefer the -transport's own error primitives and handling mechanisms, such as an -`AxiosError`, a Fetch `Response` or rejection, or an SDK/RPC error type. - -Do not serialize, deserialize, normalize, wrap, map, clone, or reconstruct an -error merely to create a uniform API shape. In particular: - -- do not replace a transport error with a generic `Error`; -- do not invent `ApiError`, `toApiError`, error-code maps, registries, or - conversion helpers; -- do not discard the original prototype, status, headers, body, code, cause, - retry metadata, or transport-specific guards; -- do not mutate errors while logging them; -- when a protocol returns a typed failure value rather than throwing, propagate - it as-is; throw that value directly when the query or mutation boundary - requires a rejection. - -If the project already has a specialized error registry, adapter, serializer, -or application-wide error flow, use it exactly as designed. Verify that it is -the established integration path before relying on it. Do not create or extend -such an abstraction just for the current API integration unless the task -explicitly requires changing the project's error architecture. - -Keep presentation outside the API module: components or the existing -application error flow own translated messages, toasts, and recovery UX. - -## Balance compatible queries through one batch client - -When many independent query functions target the same batch-capable source -operation, place one source-owned query balancer between them and the transport. -Let TanStack Query continue to own caching and identical-key deduplication. Let -TanStack Pacer collect distinct, temporally adjacent requests and flush them as -one transport batch. - -- Create one batcher per compatible source operation and auth, tenant, locale, - or routing context. Never choose transport context from the first queued item. -- Let every enqueue return its own promise. Preserve its `resolve` and `reject` - callbacks until the batch result is routed back to that caller. -- Flush after a short coalescing delay or when `maxSize` is reached. Treat - `maxSize` as a batch trigger, not as queue-capacity rejection. -- Route responses by array position only when the transport contract guarantees - stable ordering. Otherwise correlate them by an existing request identifier. -- Reject every affected caller with the original batch-level transport error. - Deliver per-item failure values to their matching callers unchanged. -- Keep results in TanStack Query's cache, not in the batcher. -- Avoid priority reordering unless correlation is explicit. -- Do not connect one query consumer's abort signal to a shared batch abort. -- Do not enable retries for writes or non-idempotent reads without an explicit - source guarantee. -- Expose `flush` only when a latency-sensitive caller genuinely needs it. - -Use atomic or manually constructed write batches only when the source contract -defines their ordering, partial-failure, and idempotency semantics. Invalidate -all affected cache prefixes after a successful or potentially applied write. - -Read `references/query-balancing.md` before implementing Pacer-based batching. -It contains a thin generic implementation and the lifecycle invariants that -prevent lost, crossed, or permanently pending query promises. - -## Verification - -Before finishing an API change, verify: - -- external calls remain behind `api/`; -- each source has one clear module owner; -- generated artifacts reproduce from their authority; -- no external DTO was manually duplicated; -- no hand-maintained generic `types.ts` was introduced; -- mock-only data stays behind one explicit boundary and does not introduce a - parallel domain model or fake integration layer; -- keys contain every response-changing input; -- query and mutation definitions are reusable outside components; -- each mutation's cache effects are explicit and tested; -- cross-source invalidation is covered; -- query batchers preserve one-to-one result routing and leave no caller promise - pending after success, failure, flush, or exposed cancellation; -- transport errors remain unchanged unless an existing project-wide error flow - explicitly owns their conversion; -- no new error wrapper, converter, serializer, or registry was invented; -- auth, retry, and error behavior is tested at the client boundary; -- typecheck, focused tests, and contract-generation drift checks pass. - -## References - -- Read `references/module-pattern.md` for a neutral source-module skeleton. -- Read `references/query-balancing.md` when multiple query functions can share - a source batch operation or when using TanStack Pacer. -- Read `references/transport-examples.md` when choosing how generated, - schema-inferred, SDK, or RPC contracts feed the same module architecture. - -## Related skills - -- Client state that does not mirror an external source: `state-management`. -- Query or mutation failure propagation, reporting, retry, and duplicate - suppression: `error-handling`. The no-invention rule above remains binding. -- User-visible query and mutation outcomes: `user-feedback`. -- Wiring mutations to form submission: `forms`. -- Broader source-tree ownership decisions: `file-structure`. -- Rendering loading, error, empty, and data states from an API query: - `components`. diff --git a/.agents/skills/api-integration/references/module-pattern.md b/.agents/skills/api-integration/references/module-pattern.md deleted file mode 100644 index a3c9e48..0000000 --- a/.agents/skills/api-integration/references/module-pattern.md +++ /dev/null @@ -1,157 +0,0 @@ -# Source Module Pattern - -This example uses TanStack Query because it makes the registries concrete. -Replace the transport and cache adapter without changing the ownership model. - -## Suggested layout - -```text -api/ - client.ts - provider.tsx - modules/ - accounts/ - index.ts - source.ts - contract.generated.ts -``` - -`source.ts` adapts the repository's typed transport. `contract.generated.ts` is -optional: it may instead live in a generated package or be unnecessary when a -typed SDK, schema registry, or RPC client already exposes the contract. - -## Shared API infrastructure - -```ts -// api/client.ts -import { QueryClient } from "@tanstack/react-query"; - -export const queryClient = new QueryClient(); -``` - -```tsx -// api/provider.tsx -import { QueryClientProvider } from "@tanstack/react-query"; -import type { PropsWithChildren } from "react"; - -import { queryClient } from "./client"; - -export function APIProvider({ children }: PropsWithChildren) { - return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; -} -``` - -## Source module - -```ts -// api/modules/accounts/index.ts -import { - infiniteQueryOptions, - mutationOptions, - queryOptions, -} from "@tanstack/react-query"; - -import { queryClient } from "@/api/client"; - -import { - accountsSource, - type AccountFilters, - type RenameAccountRequest, -} from "./source"; - -export const accountsKeys = { - all: ["accounts"] as const, - lists: () => [...accountsKeys.all, "list"] as const, - list: (filters: AccountFilters) => [...accountsKeys.lists(), filters] as const, - infinite: (filters: AccountFilters) => - [...accountsKeys.lists(), "infinite", filters] as const, - details: () => [...accountsKeys.all, "detail"] as const, - detail: (id: string) => [...accountsKeys.details(), id] as const, - mutations: () => [...accountsKeys.all, "mutation"] as const, -}; - -export async function listAccounts(filters: AccountFilters) { - return accountsSource.list({ filters }); -} - -export async function getAccount(id: string) { - return accountsSource.get({ id }); -} - -export async function renameAccount(variables: { - id: string; - request: RenameAccountRequest; -}) { - return accountsSource.rename(variables); -} - -export const accountsQueries = { - list: (filters: AccountFilters) => - queryOptions({ - queryKey: accountsKeys.list(filters), - queryFn: () => listAccounts(filters), - }), - infinite: (filters: AccountFilters) => - infiniteQueryOptions({ - queryKey: accountsKeys.infinite(filters), - initialPageParam: undefined as string | undefined, - queryFn: ({ pageParam }) => - listAccounts({ - ...filters, - cursor: pageParam, - }), - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }), - detail: (id: string) => - queryOptions({ - queryKey: accountsKeys.detail(id), - enabled: Boolean(id), - queryFn: () => getAccount(id), - }), -}; - -export const accountsMutations = { - rename: () => - mutationOptions({ - mutationKey: [...accountsKeys.mutations(), "rename"] as const, - mutationFn: renameAccount, - onSettled: (_data, _error, variables) => { - void queryClient.invalidateQueries({ queryKey: accountsKeys.lists() }); - void queryClient.invalidateQueries({ - queryKey: accountsKeys.detail(variables.id), - }); - }, - }), -}; -``` - -The source adapter, contract types, keys, operations, and cache definitions form -one reviewable integration boundary. A large module may split these into named -files without changing its public surface. - -`source.ts` may wrap generated HTTP code, a schema-inferred command transport, -or a typed RPC/SDK client. It propagates transport errors unchanged unless the -project already has a specialized error flow that owns their conversion. See -`transport-examples.md`; no variant is the default. - -## Consumer - -```tsx -const account = useQuery(accountsQueries.detail(accountId)); -const renameAccount = useMutation(accountsMutations.rename()); - -function submit(request: RenameAccountRequest) { - renameAccount.mutate( - { id: accountId, request }, - { - onSuccess: () => { - showSuccess("Saved"); - closeDialog(); - }, - }, - ); -} -``` - -The per-call callback adds UI behavior without replacing the mutation -registry's cache lifecycle. diff --git a/.agents/skills/api-integration/references/query-balancing.md b/.agents/skills/api-integration/references/query-balancing.md deleted file mode 100644 index 566703e..0000000 --- a/.agents/skills/api-integration/references/query-balancing.md +++ /dev/null @@ -1,245 +0,0 @@ -# Query Balancing with TanStack Pacer - -Use a query balancer when many independent callers request different data from -the same batch-capable source operation. The balancer coalesces nearby requests, -executes one transport call, and routes each result back to the promise created -for its original caller. - -## Contents - -- Purpose and ownership -- Minimal implementation -- Source-module integration -- Contract and lifecycle rules -- Verification - -## Purpose and ownership - -This is temporal request balancing, not server load balancing: - -```text -query A ─┐ ┌─ result A -> query A -query B ─┼─ short queue ─ batch call ─ result B -> query B -query C ─┘ └─ result C -> query C -``` - -TanStack Query and TanStack Pacer have different jobs: - -- TanStack Query caches results, deduplicates identical query keys, and manages - query lifecycle. -- TanStack Pacer groups distinct requests that arrive close together and - controls when the group executes. -- The source module owns the batch endpoint and the one-to-one mapping between - queued requests and returned results. - -Keep one long-lived balancer per batch-capable source operation. Do not create a -batcher per component or per query invocation. - -## Minimal implementation - -Use the core Pacer class for a non-React service. If the repository exposes the -same class through its framework package, follow the established import. - -```ts -import { AsyncBatcher } from "@tanstack/pacer"; - -type PendingRequest<TRequest, TResponse> = { - request: TRequest; - resolve: (response: TResponse) => void; - reject: (reason: unknown) => void; -}; - -type QueryBatcherOptions<TRequest, TResponse> = { - key: string; - maxSize: number; - wait: number; - execute: ( - requests: readonly TRequest[], - ) => Promise<readonly TResponse[]>; -}; - -export function createQueryBatcher<TRequest, TResponse>({ - key, - maxSize, - wait, - execute, -}: QueryBatcherOptions<TRequest, TResponse>) { - const batcher = new AsyncBatcher< - PendingRequest<TRequest, TResponse> - >( - async (pending) => { - const responses = await execute( - pending.map(({ request }) => request), - ); - - if (responses.length !== pending.length) { - throw new Error( - `Batch response count mismatch: expected ${pending.length}, received ${responses.length}`, - ); - } - - pending.forEach(({ resolve }, index) => { - resolve(responses[index]!); - }); - }, - { - key, - maxSize, - wait, - onError: (error, pending) => { - pending.forEach(({ reject }) => { - reject(error); - }); - }, - throwOnError: false, - }, - ); - - return { - request(request: TRequest): Promise<TResponse> { - return new Promise<TResponse>((resolve, reject) => { - void batcher.addItem({ request, resolve, reject }); - }); - }, - async flush(): Promise<void> { - await batcher.flush(); - }, - }; -} -``` - -The wrapper is intentionally thin: - -- Pacer owns collection, timing, `maxSize`, execution, and observable state. -- The wrapper owns only the caller promise and result demultiplexing. -- `throwOnError: false` prevents the ignored `addItem` promise from producing - an unhandled rejection; `onError` rejects the real caller promises with the - original error. -- The cardinality error represents a broken batch contract. It does not wrap or - convert a transport error. - -## Source-module integration - -Construct the balancer once inside the source module: - -```ts -type AccountRequest = { - id: string; -}; - -const accountDetailBatcher = createQueryBatcher< - AccountRequest, - AccountResponse ->({ - key: "accounts.detail", - maxSize: 50, - wait: 20, - execute: (requests) => accountsSource.getMany(requests), -}); - -export function getAccount(id: string) { - return accountDetailBatcher.request({ id }); -} - -export const accountsQueries = { - detail: (id: string) => - queryOptions({ - queryKey: accountsKeys.detail(id), - enabled: Boolean(id), - queryFn: () => getAccount(id), - }), -}; -``` - -Concurrent components, route loaders, and prefetches now use the same -`accountsQueries.detail` definition. They do not know whether their request was -sent alone or as part of a batch. - -If the transport returns a per-item failure envelope, deliver that envelope to -the matching caller unchanged. Let the source operation apply the project's -existing direct error handling. Do not make the generic balancer understand -domain statuses or invent a common error type. - -## Contract and lifecycle rules - -### Batch only compatible work - -All items in one batch must share: - -- source and batch operation; -- base URL or transport channel; -- authentication and tenant context; -- locale or headers that affect the response; -- retry, timeout, and consistency semantics. - -Close these dependencies over the batcher's `execute` function. Do not store a -client, session, processor, or options on every item and then use the first -item's values for the entire batch. - -### Preserve correlation - -Use positional routing only when the source guarantees response order. Do not -sort or reprioritize queued items in that case. - -When ordering is not guaranteed, send an existing correlation identifier and -route responses through a lookup: - -```ts -const pendingById = new Map( - pending.map((item) => [item.request.requestId, item]), -); - -for (const response of responses) { - pendingById.get(response.requestId)?.resolve(response); -} -``` - -Reject unmatched requests as a batch-contract violation. Never guess which -caller owns a response. - -### Preserve error identity - -- Reject every item with the original error when the entire batch call fails. -- Preserve each transport-provided per-item error or failure envelope. -- Do not serialize, normalize, or wrap errors in the balancer. -- Use an existing project-wide error flow only when the project already - requires it. - -### Keep cancellation collective - -An in-flight transport batch belongs to several callers. Aborting it because -one component unmounted would cancel unrelated queries. Do not wire an -individual query signal directly to the batcher's shared abort signal. - -If individual cancellation is required, mark or remove only that pending item -before execution and settle its promise with the original abort reason. If -exposing `cancel`, `clear`, or `reset`, settle every removed caller first; -otherwise their promises remain pending forever. - -### Retry only safe batches - -Pacer can retry a failed batch, but retrying repeats every item. Enable it only -when the whole operation is idempotent and the source defines safe retry -semantics. Keep retries off by default for mutations and mixed-effect batches. - -### Separate query balancing from write batching - -Query balancing is most useful for independent reads. A write batch has -additional atomicity, ordering, partial-success, idempotency, and invalidation -requirements. Do not send mutations through a query balancer merely because the -transport accepts an array. - -## Verification - -Test the balancer with deterministic test executors: - -- several requests inside the window produce one batch call; -- reaching `maxSize` flushes immediately; -- each caller receives the response at its own position or correlation id; -- a batch-level error rejects every caller with the same error object; -- a per-item failure reaches only its matching caller; -- a response-count or correlation mismatch settles every affected promise; -- a later batch cannot resolve promises from an earlier batch; -- explicit `flush` settles all currently queued requests; -- cancellation or clearing, when exposed, leaves no promise pending; -- retries occur only when the operation is explicitly idempotent. diff --git a/.agents/skills/api-integration/references/transport-examples.md b/.agents/skills/api-integration/references/transport-examples.md deleted file mode 100644 index 64442fd..0000000 --- a/.agents/skills/api-integration/references/transport-examples.md +++ /dev/null @@ -1,133 +0,0 @@ -# Contract and Transport Variants - -These variants are peers. Select the one supported by the source authority; -none changes the source-module, key-registry, query-registry, mutation-registry, -or invalidation pattern. - -## Generated HTTP contract - -Use this when an OpenAPI or similar machine-readable contract is authoritative. - -```ts -import createClient from "openapi-fetch"; - -import type { components, paths } from "./contract.generated"; - -export type CreateAccountRequest = - components["schemas"]["CreateAccountRequest"]; - -const accountsClient = createClient<paths>({ baseUrl }); - -export async function createAccount(request: CreateAccountRequest) { - const { data, error } = await accountsClient.POST("/accounts", { - body: request, - }); - - if (error) { - throw error; - } - - return data; -} -``` - -The generator owns `contract.generated.ts`. The module may export readable -aliases derived from it, but must not restate DTO fields. - -The same approach applies to GraphQL, protobuf/gRPC, and other contract -generators: generate the client or types, then adapt them behind the source -module. - -## Runtime schema or registry - -Use this when the source publishes executable schemas or a typed command -registry. - -```ts -import type { resources } from "@vendor/source-registry"; -import type { z } from "zod"; - -type ListAccountsParams = z.infer< - typeof resources.accounts.list.request.shape.details ->; - -export async function listAccounts(params: ListAccountsParams) { - const response = await sendTypedCommand< - typeof resources.accounts.list - >({ - action: "get", - resource: "/accounts", - details: params, - }); - - if (response.status !== "success") { - throw response; - } - - return response; -} -``` - -Types are inferred from the registry that also validates runtime messages. -The typed failure envelope is propagated unchanged. Avoid both a parallel -hand-written DTO layer and an invented error-conversion layer. - -## Typed SDK or native RPC client - -Use this when the client method already carries request and response types. - -```ts -import { AccountsNativeModule } from "@internal/accounts/native/client"; -import { queryOptions } from "@tanstack/react-query"; - -export const accountsKeys = { - all: ["accounts"] as const, - detail: (id: string) => [...accountsKeys.all, "detail", id] as const, -}; - -export const accountsQueries = { - detail: (id: string) => - queryOptions({ - queryKey: accountsKeys.detail(id), - enabled: Boolean(id), - queryFn: () => AccountsNativeModule.getAccount({ id }), - }), -}; -``` - -No local request or response aliases are required when inference remains -readable at the call site. The typed RPC contract and client factory are the -type authority. - -## Dedicated unauthenticated client - -Some operations must not use normal source middleware. Authentication refresh -is the common example: - -```ts -const sourceClient = createAuthenticatedClient<paths>(); -const authClient = createBareClient<authPaths>(); -``` - -Keep this exception explicit inside the owning source module or client -infrastructure. Do not add component-level flags that disable middleware for -individual calls. - -## Contract-derived projection - -Client code sometimes needs a subset or combination of contract types. Derive -it instead of copying it: - -```ts -type SearchParams = NonNullable< - paths["/accounts"]["get"]["parameters"]["query"] ->; - -type AccountSummary = Pick< - components["schemas"]["Account"], - "id" | "display_name" ->; -``` - -If the shape exists only for a form or view, keep it with that form or view. -It is not part of the API contract. diff --git a/.agents/skills/code-quality/SKILL.md b/.agents/skills/code-quality/SKILL.md deleted file mode 100644 index 00bafaf..0000000 --- a/.agents/skills/code-quality/SKILL.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -name: code-quality -description: Use whenever cleaning, validating, or finishing code; fixing or preventing formatting, lint, typecheck, import-order, naming, or other repository-standard violations; deciding whether the formatter or linter should autofix mechanical issues instead of editing them by hand; or running verification commands before calling work complete. ---- - -# Code Quality - -Keep code mechanically clean through the repository's own type checker, linter, -formatter, and build tooling. Treat every rule here as a default unless the -repository's configuration says otherwise; its configuration is authoritative. - -## Verification (run before done) - -Always run the repo's typecheck, lint, format, and build before declaring a -change complete. The script runner is repo-specific; these are the SAME step -expressed two ways: - -```bash -# pnpm-based repo # bun-based repo -pnpm typecheck bun run typecheck -pnpm lint bun run lint -pnpm format bun run format:check # check; *:fix to auto-fix -pnpm build bun run build -``` - -- For mechanical formatting, import-order, and autofixable lint violations, run - the repository's configured formatter or lint-fix command before editing the - affected lines manually. -- Let the tool apply the rules it owns, then inspect its diff and fix only the - remaining semantic or non-autofixable violations by hand. -- Do not imitate formatter or linter output manually when the repository already - provides the command that produces it. -- Run from the repo's canonical directory (root or the app package — follow the - repo's scripts; some lint scripts run with `--fix --max-warnings=0`). -- Fix everything to zero warnings/errors. Do not leave a red checker. -- If the change touches native/main-process code or packaging, also run the - relevant native build/dev check — see **native-integration**. - -## TypeScript: strict and honest - -Keep strict mode on. Expect (and do not disable) at least: - -```jsonc -{ - "strict": true, - "strictNullChecks": true, - "noImplicitAny": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true -} -``` - -Typing principles: - -- Prefer `unknown` + narrowing over `any`. Narrow with `typeof`, `instanceof`, - or `in` guards before accessing properties. -- Use `enum` for named constant sets; reach for `as const` objects when an enum - is heavier than the context needs, then derive `type T = typeof X[keyof typeof X]`. -- Use `satisfies` to type-check a literal without widening its inferred type. -- Derive types from their contract source, not by hand-duplication: `z.infer` - from a Zod schema, or generated types from an OpenAPI generator — adapt to the - repo's stack (see **api-integration** for contract typing). -- Add explicit return types on exported functions. - -## Path aliases, never deep relatives - -Import through the repo's configured alias (`@/...`, `@config`, `@internal/...`). -Never reach across the tree with `../../../`. - -```ts -// Good // Bad -import { UiButton } from '@/ui/UiButton' import { UiButton } from '../../../ui/UiButton' -``` - -## Linting norms - -- No unused variables. Remove them, or prefix intentionally-unused params with - `_` (e.g. `(_event) => …`). -- No stray `console`. If a log is genuinely required, scope a single - `eslint-disable-next-line` — and prefer the repo's logger (see - **logging**). -- React Hooks rules apply; keep hook dependency lists correct. -- Let the linter sort/dedupe imports. Disable rules narrowly and only with a - reason, never blanket-disable a whole file casually. - -## Import order - -Group imports, blank-line-separated, in this order (the linter usually enforces -it): - -1. React -2. External packages -3. Internal alias imports (`@/...`) -4. Relative imports (`./...`) - -```tsx -import { useState } from 'react' - -import { useQuery } from '@tanstack/react-query' - -import { UiButton } from '@/ui/UiButton' - -import { Child } from './Child' -``` - -## Formatting - -Adopt the repo's formatter — follow its `.prettierrc` (or equivalent) rather than -re-styling by hand; let the formatter own whitespace, quotes, and class sorting. -One repo's baseline, as an example of the kind of conventions to honor: 2-space -indent, no semicolons, single quotes (incl. JSX), trailing commas everywhere, -`arrowParens: avoid`, and a Tailwind class-sorting plugin. Match whatever the -target repo configures; do not impose this baseline on a repo that disagrees. - -## Naming - -- Components: `PascalCase`. Hooks: `camelCase` with `use` prefix. -- Module-level constants: `SCREAMING_SNAKE_CASE`. -- Files: `PascalCase` for components, `camelCase` for utilities. -- CSS: kebab-case utility classes (via Tailwind). The literal-class / `cn()` - rule lives in **components**. - -## Cross-links - -- React component behavior, effect alternatives, and internal ordering — - **components**. -- Logger instead of `console` — **logging**. -- Native/main-process build checks — **native-integration**. diff --git a/.agents/skills/components/SKILL.md b/.agents/skills/components/SKILL.md deleted file mode 100644 index 9ed3a17..0000000 --- a/.agents/skills/components/SKILL.md +++ /dev/null @@ -1,412 +0,0 @@ ---- -name: components -description: Use whenever work creates, modifies, refactors, reviews, styles, composes, or places any React component or conditional JSX; selects or adds a Ui* primitive; ports shadcn/Base UI components; designs compound, polymorphic, Context, or controlled/uncontrolled APIs; handles parent-child layout ownership, hooks and effects, skeletons, loading/error/empty/data states, or render-prop data loaders. Covers UI primitives, common business components, and feature- or page-owned components. ---- - -# Components - -Treat the repository's existing `ui/` directory as the design-system authority. -Its `Ui*` components are local adaptations of official **shadcn** components built -on **Base UI** (`@base-ui/react`), commonly using -`class-variance-authority` (cva) and `cn()` (tailwind-merge). - -Treat shadcn as an upstream source of component code and documentation, not as a -registry-managed project structure. Preserve the local `Ui*` conventions instead -of introducing Radix, HeroUI, another primitive system, or a parallel feature-local -design system. - -## Distinguish Primitives From Product Components - -Reserve the `ui/` directory and `Ui*` prefix for reusable design primitives and -their stable specializations. - -Components that compose those primitives with product behavior are not `Ui*` -components. Name them after their business or interface responsibility and place -them at the nearest common owner that contains all of their real consumers: - -- one consumer → keep it inside that component or page boundary; -- several consumers inside one page subtree → move it only to their nearest - shared owner inside that page; -- several pages or subfeatures inside one feature → move it to that feature's - contextual `components/` boundary; -- consumers from genuinely different features or application areas → place it - in the shared business-component boundary, usually `common/`. - -Do not promote a component based on hypothetical reuse, import count, or reuse -within a single owner. Move it upward only as real consumers cross ownership -boundaries: - -```text -page/components - ↑ -resource-or-feature/components - ↑ -common -``` - -In the usual application layout, `common/` sits beside `ui/`: - -```text -src/ -├── ui/ # Ui* design primitives -└── common/ # Shared product and business components -``` - -Components in `common/` use names that describe their product or business -responsibility; the `Ui*` prefix remains reserved for primitives. Treat -`common/` as a real owner, not as a catch-all. Put only components with -demonstrated use across genuinely different ownership areas there, keep -feature- or page-private components local, and organize complex common -components recursively by responsibility. - -Follow **file-structure** for the exact directory. A private child belongs inside -its owning component or page, commonly under its contextual `components/` -folder. Do not move a component into `ui/` merely because it is reusable, -complex, or implemented as a compound component. - -The component-design rules below are not limited to `Ui*` primitives. Apply -composition, Context, controlled/uncontrolled state, effect, ownership, and -render-state guidance to product components whenever those patterns make the -component simpler and more coherent. - -## Inspect The Existing UI System First - -Before implementing or styling any component: - -1. Decide whether the responsibility is a design primitive or a product - component, then inspect the nearest current owner. -2. Locate the repository's `ui/` directory from existing imports, aliases, and - source structure so the component reuses the available primitives. -3. Inspect its filenames and search for the needed behavior, not only the exact - name you expect. -4. Read the closest existing `Ui*` primitive, its named variations, and a few - real consumers. Read stories when the repository has them. -5. Reuse the existing primitive or variation when it already expresses the - required semantics and state. -6. Add a shared primitive only when the capability is genuinely missing. - -Do not enumerate the available primitives in this skill; discover the target -repository's current UI surface every time. Do not hand-roll or restyle an -interactive control before checking whether its `Ui*` equivalent already exists. - -## Port Missing Primitives From Official Sources - -When the required primitive is absent: - -1. Read the current official shadcn documentation and the matching Base UI API. -2. Select the Base UI implementation, not a Radix or another-library variant. -3. Obtain the official source manually. Use the shadcn CLI only as an optional - way to inspect or download source; do not let it initialize, restructure, or - overwrite the application's `ui/` directory. -4. Inspect analogous local primitives to learn import paths, tokens, formatting, - exports, and file shape. -5. Port the source into the local `Ui*` layer and adapt only its integration: - local `cn`, theme tokens, icon system, `data-slot` values, import aliases, and - `Ui*` exports. -6. Preserve the upstream semantics, accessibility behavior, keyboard behavior, - state attributes, prop forwarding, and ref contract. -7. Add stories or focused tests when that is the repository's established - practice, then run the normal verification through **code-quality**. - -Never paste an upstream component directly into a feature. The local `Ui*` -adaptation becomes the reusable boundary. - -## Ui* Wrapper Convention - -A `Ui*` module adapts one or more shadcn/Base UI parts into the application's -reusable design primitive. - -- Keep internal component names aligned with upstream, then alias every public - export with the `Ui` prefix: - `export { Button as UiButton, buttonVariants as UiButtonVariants }`. -- For multi-part primitives, preserve the parts and alias each public part: - `DialogContent as UiDialogContent`, `DialogTrigger as UiDialogTrigger`, and so - on. -- Put stable `data-slot` attributes on rendered parts. Preserve upstream props - and refs instead of narrowing the primitive accidentally. -- Use cva for real variant axes and compose caller classes through `cn(...)`. -- Prefer an existing wrapper before writing a new one. Add a new `Ui*` only for a - genuinely reusable design primitive — never for one feature's one-off layout. -- Use the icon system the repo already established; do not introduce another. - -## Keep Styling At The Owning Layer - -The shared `Ui*` layer owns the visual language of controls and surfaces: -colors, typography treatments, backgrounds, borders, radii, shadows, and -hover/focus/active/disabled/invalid/ARIA state styling. - -Feature and page components should use Tailwind primarily for structure: -layout, positioning, sizing, spacing, responsive arrangement, and placement of -children. Passing `className` for those structural concerns is expected. - -Do not rebuild a primitive's visual or interaction states in a consumer. If a -visual treatment is intentional and reusable, add a clearly named variation -beside the primitive. If it is unique feature composition rather than a reusable -primitive treatment, compose existing `Ui*` parts without redefining their -owned states. - -## File & Folder Layout — base + variations - -Choose one of three shapes from the component's actual responsibility: - -**1. Self-contained primitive → a flat file** `Ui{Name}.tsx` at the ui root -(`UiCard.tsx`, `UiAlert.tsx`, `UiDialog.tsx`). It may export one component or -several upstream compound parts. - -**2. Primitive with specializations → a folder `Ui{Name}/`** named after the -component and containing: - -- **`base.tsx`** — the canonical local adaptation of the official component. It - contains the shared primitive behavior, state styles, and cva variants and - exports `Ui{Name}` plus any public variants helper. -- **Named visual variations beside `base.tsx`** — one file per stable treatment, - kebab-case named for what it is (`outline-primary.tsx`, - `ghost-destructive.tsx`, `outline.tsx`, `simple.tsx`). Each one: - - imports the original: `import { UiButton } from './base'` - - is a **`default export`** named `Ui{Name}{Variation}` (`UiButtonOutlinePrimary`, - `UiInputOutline`) - - locks a base variant and/or layers extra classes via `cn('…extra', className)`, - narrowing the props it now fixes (`Omit<ComponentProps<typeof UiButton>, 'variant'>`) - - or composes richer stable structure around `base`, such as affixes, field - chrome, or another repeated integration. -- No barrel — import the exact file: `@/ui/UiButton/base`, `@/ui/UiButton/outline-primary`. - -```tsx -// UiButton/outline-primary.tsx -import { UiButton } from './base' - -export default function UiButtonOutlinePrimary({ - className, - ...rest -}: Omit<ComponentProps<typeof UiButton>, 'variant'>) { - return ( - <UiButton - {...rest} - variant='outline' - className={cn('border-primary! text-primary bg-transparent!', className)} - /> - ) -} -``` - -**3. Bespoke/composed component (not a variant family) → `Ui{Name}/index.tsx`** -(+ assets or `components/` subparts): `UiIcon`, `UiMarkdown` (+ `styles.scss`), -`UiToaster` (+ `components/`). - -**Rule of thumb:** a recurring restyle of a `Ui*` belongs in a **named variation -file** under the component folder — not scattered as inline `className` overrides -across features. `className` does merge through `cn()` (caller overrides win), but -promote anything reused into a variation so the base stays the shared authority. - -### Keep the base free of feature-specific changes - -Adapt `base.tsx` intentionally when importing, updating, or correcting the shared -primitive itself. Do not edit it to satisfy one feature's visual request. Build -that treatment around the base through a sibling variation or composition. - -## Polymorphism (render prop, not Slot) - -Base UI primitives are polymorphic via the `render` prop / `useRender` hook + -`mergeProps` — there is no Radix `asChild`/`Slot` here. To let a caller swap the -rendered element, forward `render` or drive it with `useRender`. Ensure custom -render targets accept the forwarded ref and spread the received props onto their -underlying DOM element. Preserve correct element semantics; polymorphism is not -permission to make a button behave as a link or vice versa. - -## Compound Components - -Use the compound pattern when a primitive or business component has meaningful -parts that should compose independently while sharing one coherent behavior. -This includes shadcn-style parts such as `Select.Trigger`/`Select.Content` and -higher-level feature components decomposed into Root, Trigger, Content, -Indicator, or similar roles. - -Compound parts do not require Context by default. Use the local Context boundary -below only when the parts genuinely need shared state or behavior. - -## Context As A Local Composition Boundary - -Use Context when one coherent component, feature, or page subtree has several -parts that need the same state, derived values, or actions, and explicit props -would cause prop drilling or fragmented ownership. - -Place the Provider at the narrowest owner that contains all real consumers. -Keep state and actions in that owner and expose them through a guarded consumer -hook. Derive the context value type from its owning value hook or factory when -practical instead of declaring a parallel type manually. - -```tsx -const XContext = createContext<XState | null>(null) -const useXContext = () => { - const ctx = useContext(XContext) - if (!ctx) throw new Error('X parts must be used within X') - return ctx -} -``` - -Do not introduce Context when local state and explicit props remain clearer. Do -not use it to duplicate server state owned by the data-fetching layer or durable -client state owned by the state-management layer. - -## Controlled / Uncontrolled - -Choose the state contract that fits the component. Do not require every -component to support both modes. - -- Use an uncontrolled mode when the component can own its interaction state. -- Use a controlled mode when a parent must own that state. -- Support both when the same reusable component genuinely needs autonomous and - parent-driven usage. Accept `defaultX` for the uncontrolled seed and - `x` + `onXChange` for controlled usage; never switch modes after mount. -- Use `x !== undefined` only when `undefined` unambiguously means uncontrolled. - If `undefined` is a valid controlled value, define an explicit contract instead. - -## Avoid useEffect - -Treat `useEffect` as exceptional. Before adding one, prove that the component -must synchronize with a lifecycle that no existing declarative abstraction -already owns. The mere presence of an external source does not justify an -effect: when the source fits an async or server-state model, prefer the -repository's data-fetching layer, such as TanStack Query, so request lifecycle, -caching, retries, deduplication, and cancellation stay outside the component. - -Use an effect only as the final option for synchronization that genuinely -belongs to the component, such as an imperative browser API, a third-party -imperative library, or a subscription that cannot use `useSyncExternalStore`. -Do not use it as a general-purpose way to run component logic. - -| Instead of `useEffect` for… | Prefer | -| --- | --- | -| Fetching data | The data-fetching layer, such as TanStack Query — see **api-integration** | -| Derived or computed state | Compute during render, or use `useMemo` for expensive computation | -| Subscribing to an external store | `useSyncExternalStore` or store selectors — see **state-management** | -| Responding to a prop change | Compute during render or lift state up | -| Resetting state on prop change | A `key` prop that remounts the owned subtree | -| Handling a user action | The event handler itself | - -If a React data-flow or event-driven pattern expresses the behavior, use it -instead of adding an effect. - -## Parent Owns Placement - -A child owns its internal structure, content, internal visual treatment, and -local show/hide behavior. It must NOT own the styles that place it within its -parent: surrounding layout, positioning, sibling-dependent sizing, route-level -offsets, or dock dimensions. Apply those external layout styles through a parent -wrapper so the child renders correctly in any correctly-sized slot. - -This rule governs runtime layout and styling ownership, not source-file -placement. Locate the component itself according to **file-structure**. - -### Docked / sheet surfaces — two-layer split - -1. A **generic container** (bar/sheet) owns only open/close behavior and a content slot. -2. The **owning page or layout** applies absolute/flex/grid placement and - surrounding offsets through its wrapper. -3. The **business content** fills the provided slot without knowing the page, - shell, sibling panels, or external geometry. - -The container never decides its own position in the page. - -## Component internal ordering - -Order a component file as imports → types/interfaces → component. Inside the -component, call hooks before any conditional return, then keep computed values, -handlers, and render: - -```tsx -export function MyComponent({ title }: Props) { - const [open, setOpen] = useState(false) - const items = useMemo(() => data?.filter(item => item.active), [data]) - const handleClick = () => setOpen(true) - - return <UiButton onClick={handleClick}>{title}</UiButton> -} -``` - -## Skeletons - -A skeleton must preserve the real content's visible geometry: the same occupied -space, primary dimensions, and row/section arrangement. It does not need to copy -the real DOM or every decorative detail, but swapping in data must not cause a -layout shift. For lists, render several skeleton rows to represent a realistically -populated list. - -## Ordered Async-State Rendering - -For mutually exclusive async states in one UI region, use one ordered waterfall: -loading → error → empty → data. Base blocking loading and error states on the -absence of usable data, not on query flags alone. Existing cached or stale data -normally remains the data state during a background refetch or background error; -surface secondary status separately when the product requires it. - -Prefer an inline IIFE with sequential early returns. Avoid ternary expressions -for conditional rendering in JSX; never build nested or chained render -ternaries. They quickly obscure branch priority and turn JSX into an unreadable -conditional tree. Do not scatter conditionals that can overlap or render -contradictory states. - -```tsx -{(() => { - const hasUsableData = query.data !== undefined - - if (!hasUsableData && query.isPending) return <ListSkeleton /> - if (!hasUsableData && query.isError) return <ErrorState /> - if (!query.data?.items.length) return <EmptyState /> - return <List items={query.data.items} /> -})()} -``` - -Render shared chrome such as the header, title, and primary action once outside -the waterfall. Give separate regions separate waterfalls when they can load or -fail independently. - -## Render-Prop Data-Loading Components (abstract-requesting) - -Consider a thin render-prop requesting component when referenced entities are -loaded repeatedly across UI contexts or direct request wiring would clutter -their consumers. Let it accept the entity identity, call the existing query -options through the shared data-fetching client, and expose the query result to -the render prop. Keep loading, error, empty, and presentation decisions inline -at the call site: - -```tsx -<EntityById id={id}> - {({ data, isPending, isError }) => { - if (data === undefined && isPending) return <UiSkeleton /> - if (data === undefined && isError) { - return <UiAlert>Unable to load</UiAlert> - } - if (data === undefined) return null - return <EntityView entity={data} /> - }} -</EntityById> -``` - -Do not create one reflexively for every entity or embed presentation policy -inside it. Place it at the nearest common owner of its real consumers according -to **file-structure**. - -The render-prop boundary improves declarative composition, not network -performance by itself. Query caching, deduplication, key factories, batching, -and request balancing remain responsibilities of the shared data layer → see -**api-integration**. - -## Cross-References - -- Route/page contracts, layouts, params, and navigation → **routing**. -- Physical file placement, visibility, and ownership boundaries → - **file-structure**. -- Form state, `register`, `Controller`, and validation → **forms**. -- Query and mutation options, keys, cache updates, invalidation, batching, and - request balancing → **api-integration**. -- Durable client state, selectors, persistence, and external-store - subscriptions → **state-management**. -- User-facing labels, placeholders, accessibility text, and messages when the - application uses i18n → **localization**. -- Error propagation, reporting, retry, and recovery boundaries → - **error-handling**. -- Error, success, warning, fallback, and recovery presentation → - **user-feedback**. -- Structured diagnostics and transports → **logging**. -- Lint, formatting, typecheck, and build verification → **code-quality**. diff --git a/.agents/skills/curate-engineering-knowledge/SKILL.md b/.agents/skills/curate-engineering-knowledge/SKILL.md deleted file mode 100644 index 3bc65ea..0000000 --- a/.agents/skills/curate-engineering-knowledge/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: curate-engineering-knowledge -description: Author or materially update engineering-facing current knowledge from verified implementation and reviewed product authority. Use when a completed change, source-first reconstruction, or explicit implementation-drift repair is ready to record architecture, repository ownership, contracts, data or control flow, runtime behavior, operations, or technical constraints. Do not use merely because an engineer asks how existing code works; answer that read-only question through knowledge navigation and Graphify. Keep product meaning in linked product documents and never infer intended behavior from code alone. ---- - -# Curate Engineering Knowledge - -Write the technical realization of current project truth without duplicating or -silently redefining product meaning. - -Read [the engineering writing contract](references/engineering-writing-contract.md) -before first-time promotion. Use -[the engineering concept template](assets/engineering-concept.md) for a new -document. - -## Establish the implementation - -1. Identify the owning Area, product concepts, repository, and exact clean - source revision. -2. Invoke `analyze-with-graphify` for navigation and relationship coverage. -3. Directly inspect source, tests, contracts, configuration, and runtime - evidence at the pinned revision. -4. Distinguish implemented behavior, architectural rationale, ownership, - contract, policy, history, and external claims. Apply the authority required - by each class. -5. Treat code as implementation authority only. Link accepted product meaning; - never derive it from code. - -## Author the engineering view - -1. Declare `view: engineering`, `purpose: technical-realization`, and include - `engineer` or `operator` in `audience`. -2. Explain responsibility, current implementation, ownership boundaries, data - and control flow, contracts and invariants, failure behavior, operations, - and verification. -3. Name exact code surfaces only when they help maintenance or verification. - Pin material claims to repository, commit, path, and optional symbol. -4. Link the product concept that gives the implementation meaning. Keep - product behavior in that product document and describe only the technical - consequence here. -5. Record partial, absent, accidental, retired, unknown, or drifted delivery - honestly. Do not repair intent by rewriting it to match code. -6. Keep cross-Area architecture at `knowledge/architecture/`, repository - ownership at `knowledge/repositories/`, and Area-owned implementation at - `knowledge/areas/<area>/implementation/`. - -The path a page occupies is where it will live, whichever route it takes to get -there. Work from a change bundle writes it under that bundle's `promotion/` -directory at exactly that path, and the maintainer's word is what copies it into -`knowledge/`. Work from a reconstruction or intake case writes it into -`knowledge/` directly, because that promotion is what its closure waits for. - -## Verify before stable - -1. Invoke `verify-knowledge-quality` after the body and evidence are complete. -2. Resolve gaps in source coverage, product linkage, failure behavior, and - claims that exceed their evidence. -3. Run `wfctl knowledge hash --concept <path>` and bind the quality receipt and - normal verification to that hash. -4. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. -5. Do not report completion while any gate fails. diff --git a/.agents/skills/curate-engineering-knowledge/agents/openai.yaml b/.agents/skills/curate-engineering-knowledge/agents/openai.yaml deleted file mode 100644 index 1f6d445..0000000 --- a/.agents/skills/curate-engineering-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Curate Engineering Knowledge" - short_description: "Write verified technical realization knowledge" - default_prompt: "Use $curate-engineering-knowledge to document verified implementation and architecture without duplicating product meaning." diff --git a/.agents/skills/curate-engineering-knowledge/assets/engineering-concept.md b/.agents/skills/curate-engineering-knowledge/assets/engineering-concept.md deleted file mode 100644 index dc2e4d3..0000000 --- a/.agents/skills/curate-engineering-knowledge/assets/engineering-concept.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -type: "<Implementation|Architecture|Repository|Contract|Operational Concept>" -title: "<technical title>" -description: "<one-sentence current technical responsibility>" -status: draft -view: engineering -purpose: technical-realization -audience: - - engineer - - operator - - maintainer -area: "<primary-area when Area-owned>" -capabilities: [] -authority: - - implementation -generated: - by: "<producer>/<version>" - at: "<ISO-8601>" -verified: [] -x-wf: - relations: [] - quality: - status: pending -sources: - - id: "<source-id>" - kind: source-code - resource: "git:<repository>@<40-character-commit>#<path>[:<symbol>]" - title: "<pinned implementation evidence>" ---- - -# Responsibility - -State the technical responsibility and its maintenance boundary.[^source-id] - -# Current implementation - -Explain how the responsibility is implemented at the pinned revision. - -# Boundaries and ownership - -Name repositories, components, dependencies, and ownership transitions. - -# Data and control flow - -Trace material inputs, state changes, outputs, and asynchronous boundaries. - -# Contracts and invariants - -State interfaces, schemas, protocols, invariants, and compatibility constraints. - -# Failure and operational behavior - -Explain failure modes, recovery, observability, security, and operational -constraints, or state why a concern does not apply. - -# Verification - -Link the source, tests, runtime checks, and limitations that support this -document. - -# Product knowledge - -Link the stakeholder-facing product concepts that explain why this -implementation exists. Do not duplicate their product explanation. - -# Relationships - -Link the parent Area and related engineering concepts. Mirror material -semantic links in `x-wf.relations`. - -[^source-id]: Direct pinned source evidence. - -Before changing `status` to `stable`, invoke `verify-knowledge-quality`, then -run `wfctl knowledge hash --concept knowledge/.../<concept>.md`. Replace -`x-wf.quality` with a passed receipt containing `by`, `at`, `content_hash`, and -all required checks. Add a current `verified` event with the same hash. diff --git a/.agents/skills/curate-engineering-knowledge/references/engineering-writing-contract.md b/.agents/skills/curate-engineering-knowledge/references/engineering-writing-contract.md deleted file mode 100644 index 2488d36..0000000 --- a/.agents/skills/curate-engineering-knowledge/references/engineering-writing-contract.md +++ /dev/null @@ -1,51 +0,0 @@ -# Engineering writing contract - -## Reader contract - -Write for engineers and operators who already understand the product concept -or can follow its link. Let them answer: - -1. What technical responsibility does this surface own? -2. Where and how is it implemented at the pinned revision? -3. How do data and control move through it? -4. Which contracts, invariants, and ownership boundaries apply? -5. How does it fail and how is it operated? -6. What evidence verifies the claims? - -## Separation rules - -- Link product meaning; do not restate it as technical prose. -- Never infer accepted intent, correctness, or rationale from code alone. -- Keep repository and symbol details out of product documents. -- Keep implementation detail here only when it helps understand, change, - operate, or verify the system. -- State uncertainty and drift explicitly. -- Separate current implementation from historical implementation and rejected - alternatives. - -## Required sections - -- `Responsibility` -- `Current implementation` -- `Boundaries and ownership` -- `Data and control flow` -- `Contracts and invariants` -- `Failure and operational behavior` -- `Verification` -- `Product knowledge` -- `Relationships` - -Use `Not applicable` with a reason when a section genuinely does not apply. - -## Method basis - -- C4 uses explicit abstraction levels for different audiences: - https://c4model.com/introduction -- arc42 separates stakeholder goals from hierarchical technical building - blocks: - https://docs.arc42.org/section-1/ -- Spec Kit separates product what/why from implementation how: - https://github.github.com/spec-kit/reference/agentic-sdd.html -- Architecture Decision Records preserve rationale and supersession separately - from current implementation: - https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions diff --git a/.agents/skills/curate-product-knowledge/SKILL.md b/.agents/skills/curate-product-knowledge/SKILL.md deleted file mode 100644 index 58a0062..0000000 --- a/.agents/skills/curate-product-knowledge/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: curate-product-knowledge -description: Author or materially update stakeholder-facing current product knowledge after its claims have independent authority. Use when an approved change, reviewed reconstruction, confirmed intake candidate, source audit, or explicit maintainer decision is ready to create or correct an Area, capability, use case, product flow, domain concept, product rule, vision statement, delivery summary, or evolution summary. Do not use for ordinary explanation, discovery, brainstorming, review-only requests, or unverified raw ideas. Separate accepted intent from observed delivery and keep implementation details in linked engineering documents. ---- - -# Curate Product Knowledge - -Write the product view of verified project truth. Make it understandable to a -client or product manager without requiring source code, API, architecture, or -repository knowledge. - -Read [the product writing contract](references/product-writing-contract.md) -before authoring or materially rewriting a product document. Use -[the product concept template](assets/product-concept.md) for a new concept. - -## Establish authority - -1. Work from the knowledge root and identify the primary Area. -2. Read the relevant Area index, product concepts, current decisions, and - linked engineering concepts in full. -3. Separate: - - accepted intent and product meaning; - - currently observed delivery; - - alignment or drift between them; - - planned, rejected, superseded, and unknown claims. -4. Require explicit maintainer authority for intent, product meaning, rules, - normative ownership, and product decisions. -5. Require pinned source and fresh checks for delivery claims. Invoke - `analyze-with-graphify` in every relevant exact leaf before direct source - and test inspection. -6. Never use raw, intake, search results, a compiled graph, or agent prose as - authority. - -## Author the product view - -1. Declare `view: product`, `purpose: current-behavior`, and include - `stakeholder` in `audience`. -2. State the current answer first. Explain what the product provides, who it - serves, observable behavior, rules, outcomes, boundaries, exceptions, - delivery state, examples, and meaningful evolution. -3. Use the vocabulary a domain expert or client would use. Explain necessary - domain terms on first use. - For a `Domain Concept`, explicitly record the canonical term, concise - definition, contextual boundary, accepted aliases, and names to avoid. - During unresolved discussion, keep proposed terms in the active change - record rather than silently changing current vocabulary. -4. Describe outcomes and behavior, not classes, functions, endpoints, schemas, - storage, messages, packages, repositories, or source paths. -5. Keep `Engineering details` link-only. Put technical explanations in a - document authored with `curate-engineering-knowledge`. -6. Use present tense only for behavior supported by the declared delivery - state. Say plainly when a capability is absent, partial, retired, unknown, - or accepted but not yet available. Never present planned or uncertain - behavior as currently available. -7. Preserve material exceptions and conditions. Plain language may simplify - wording but must not simplify away meaning. -8. Keep current truth at one stable path. When a decision changes, update the - current product explanation and link the decision lineage; do not copy the - whole Area into version folders. -9. Attribute every material claim to an authoritative source with matching - source IDs and footnotes. Do not expose machine-local paths. - -## Area indexes - -Treat `knowledge/areas/<area>/index.md` as the primary stakeholder page for an -Area. Use the Area template owned by `curate-project-knowledge`. Keep it -product-first and bounded: - -- summarize rather than flatten every child document; -- link capabilities, use cases, rules, current decisions, and evolution; -- show delivery honestly; -- keep `Engineering details` as links with short nontechnical labels. - -The path a page occupies is where it will live, whichever route it takes to get -there. Work from a change bundle writes it under that bundle's `promotion/` -directory at exactly that path, and the maintainer's word is what copies it into -`knowledge/`. Work from a reconstruction or intake case writes it into -`knowledge/` directly, because that promotion is what its closure waits for. - -## Verify before stable - -1. Invoke `verify-knowledge-quality` after the substantive body is complete. -2. Resolve every failed or uncertain rubric item. -3. Run `wfctl knowledge hash --concept <path>` and bind both the semantic - quality receipt and normal verification to that content hash. -4. Use `status: stable` only after the quality receipt is current, all - authority requirements pass, and normative claims have human verification. -5. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. -6. Do not report completion while any gate fails. diff --git a/.agents/skills/curate-product-knowledge/agents/openai.yaml b/.agents/skills/curate-product-knowledge/agents/openai.yaml deleted file mode 100644 index 0a53d11..0000000 --- a/.agents/skills/curate-product-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Curate Product Knowledge" - short_description: "Write verified stakeholder-facing product knowledge" - default_prompt: "Use $curate-product-knowledge to author verified current product behavior and canonical domain language for nontechnical stakeholders." diff --git a/.agents/skills/curate-product-knowledge/assets/product-concept.md b/.agents/skills/curate-product-knowledge/assets/product-concept.md deleted file mode 100644 index d2fc09b..0000000 --- a/.agents/skills/curate-product-knowledge/assets/product-concept.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -type: "<Product Capability|Product Rule|Use Case|Product Flow|Domain Concept>" -title: "<human product title>" -description: "<one-sentence current product meaning>" -status: draft -view: product -purpose: current-behavior -audience: - - stakeholder - - maintainer - - domain-expert -area: "<primary-area>" -capabilities: [] -authority: - - product-meaning - - implementation -generated: - by: "<producer>/<version>" - at: "<ISO-8601>" -verified: [] -realization: - intent: "<accepted|superseded>" - delivery: "<absent|partial|implemented|verified|retired|unknown>" - alignment: "<aligned|drifted|unknown>" - assessed_at: "<ISO-8601>" -x-wf: - relations: [] - quality: - status: pending -sources: - - id: "<maintainer-source-id>" - kind: maintainer-decision - resource: "<project-change or project-reconstruction decision>" - title: "<reviewed product authority>" - author: "human:<reviewer-id>" - - id: "<delivery-source-id>" - kind: source-code - resource: "git:<repository>@<40-character-commit>#<path>[:<symbol>]" - title: "<pinned delivery evidence>" ---- - -# What this provides - -Explain the recognizable product outcome and why it matters.[^maintainer-source-id] - -# Who it serves - -Name the people, roles, or neighboring capabilities that rely on it. - -# Domain language - -Define terms introduced or owned by this concept. For a `Domain Concept`, -state the canonical term, its contextual boundary, accepted aliases, and names -to avoid. Otherwise state `No new terms` when the document introduces none. - -# Current behavior - -Explain what happens now in observable product terms. Keep accepted intent and -observed delivery distinct when they differ.[^delivery-source-id] - -# Rules and outcomes - -State the decisions, state changes, and outcomes that govern the behavior. - -# Boundaries and exceptions - -Preserve material limits, conditions, exceptions, and explicit non-goals. - -# Delivery - -Say plainly whether the behavior is available, partial, absent, retired, or -uncertain, and what that means for the reader. - -# Examples - -Give one or more concrete domain examples. Do not use code or API examples. - -# Evolution - -Summarize only meaningful changes needed to understand the current behavior. -Link full decision records for rationale and lineage. - -# Related knowledge - -Link the parent Area, related capabilities, rules, use cases, flows, and -current decisions. Mirror material semantic links in `x-wf.relations`. - -# Engineering details - -Link separately authored engineering documents. Do not summarize their -implementation here. - -[^maintainer-source-id]: Explicit reviewed product authority. -[^delivery-source-id]: Direct pinned evidence for current delivery. - -Before changing `status` to `stable`, invoke `verify-knowledge-quality`, then -run `wfctl knowledge hash --concept knowledge/.../<concept>.md`. Replace -`x-wf.quality` with a passed receipt containing `by`, `at`, `content_hash`, and -all required checks. Add a current `verified` event with the same hash. diff --git a/.agents/skills/curate-product-knowledge/references/product-writing-contract.md b/.agents/skills/curate-product-knowledge/references/product-writing-contract.md deleted file mode 100644 index c05d9d0..0000000 --- a/.agents/skills/curate-product-knowledge/references/product-writing-contract.md +++ /dev/null @@ -1,87 +0,0 @@ -# Product writing contract - -## Reader contract - -Write for a product manager, client, maintainer, or domain expert who wants to -understand what the product does without learning how the software is built. -The reader should be able to answer: - -1. What does this provide and why does it matter? -2. Who uses or depends on it? -3. Which domain terms does it own, and what do they mean here? -4. What happens now in observable product terms? -5. Which rules, outcomes, boundaries, and exceptions apply? -6. Is it available, partial, absent, retired, or uncertain? -7. What changed materially and where can the rationale be found? - -This is neither end-user help nor a PRD. It is the stakeholder view of current, -verified product knowledge. Proposed behavior stays in active change records. - -## Language rules - -- Lead with the current answer. -- Prefer short concrete sentences and active voice. -- Use domain language; define necessary terms on first use. -- Reuse canonical Area terminology. Record aliases and discourage overloaded - names rather than silently switching vocabulary. -- Describe outcomes, choices, state changes, and visible consequences. -- Preserve conditions, exceptions, and non-goals. -- Replace internal identifiers with human names. -- Do not include code fences, inline code, API routes, source paths, data - schemas, class or function names, storage mechanisms, package names, or - protocol details. -- Do not say "the system" when the specific product actor or capability is - known. -- Do not call planned behavior current. Pair every present-tense delivery claim - with evidence and an explicit realization state. - -## Required sections - -- `What this provides` -- `Who it serves` -- `Domain language` -- `Current behavior` -- `Rules and outcomes` -- `Boundaries and exceptions` -- `Delivery` -- `Examples` -- `Evolution` -- `Related knowledge` -- `Engineering details` - -Use `Not applicable` with a short reason rather than deleting a section whose -absence could hide an unexamined concern. `Engineering details` contains links -only; it never summarizes implementation. - -## Abstraction test - -Fail the product view when any of these are true: - -- a stakeholder needs engineering knowledge to understand the main answer; -- replacing an implementation would require rewriting product behavior even - though the behavior did not change; -- technical nouns outnumber product or domain nouns; -- an important exception disappeared during simplification; -- the text implies delivery that the realization state or evidence does not - support; -- history overwhelms the current answer; -- a raw candidate or agent inference appears as authority. - -## Method basis - -The contract combines established boundaries rather than inventing one prose -style: - -- Diátaxis separates documentation by reader need: - https://diataxis.fr/ -- Spec Kit keeps product specification focused on what and why, with technical - how in a separate plan: - https://github.github.com/spec-kit/reference/agentic-sdd.html -- GOV.UK Content Design starts from user need and plain language: - https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/plan-manage-content/understand-content-design/ -- W3C clear-content guidance requires understandable language and structure: - https://www.w3.org/WAI/WCAG2/supplemental/objectives/o3-clear-content/ -- Cucumber BDD uses concrete examples to align business and technical meaning: - https://cucumber.io/docs/bdd/ -- Domain Storytelling validates domain behavior with domain experts: - https://domainstorytelling.org/ diff --git a/.agents/skills/curate-project-knowledge/SKILL.md b/.agents/skills/curate-project-knowledge/SKILL.md deleted file mode 100644 index 5fa7d33..0000000 --- a/.agents/skills/curate-project-knowledge/SKILL.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -name: curate-project-knowledge -description: Orchestrate promotion of independently verified claims into a workflow project's current OKF knowledge bundle. Use when a completed change, reconstruction case, raw-intake candidate, source audit, external source, or maintainer decision is ready to update durable product knowledge, engineering knowledge, decision lineage, Area navigation, or project history. Route stakeholder-facing content to curate-product-knowledge, technical realization to curate-engineering-knowledge, and every material document through verify-knowledge-quality. Never copy raw input into knowledge or let code define product intent. ---- - -# Curate Project Knowledge - -Coordinate the promotion boundary. Do not author product and engineering views -as one blended document. - -Read [the knowledge model](references/knowledge-model.md) before first-time -promotion, a new Area, or decision migration. - -## Accepted inputs - -A promotion may start from: - -- a closed change, whose pages are written under its own `promotion/` directory - and enter `knowledge/` only when the maintainer approves them; -- confirmed raw-intake candidate IDs with independent authority; -- confirmed source-first reconstruction candidate IDs; -- directly inspected source and tests at an exact Git revision; -- a primary external source; -- an explicit current maintainer decision. - -`raw/`, intake prose, QMD results, compiled graphs, Graphify output, and -agent-written summaries are never authority. - -## Route each durable claim - -Classify claims before selecting files: - -| Durable concern | View and owner | -| --- | --- | -| Current product purpose, capability, use case, flow, domain concept, rule, delivery summary, or Area evolution | Invoke `curate-product-knowledge` | -| Current implementation, architecture, repository ownership, contract, data/control flow, runtime, or operations | Invoke `curate-engineering-knowledge` | -| Durable choice that is hard to reverse, surprising without context, or resolves a real tradeoff | Use the decision template, then invoke `verify-knowledge-quality` | -| Primary external context | Use `view: reference`, preserve the primary source, then verify | -| Trusted unresolved current question | Use `view: uncertainty`, state missing authority, then verify | -| Proposed, rejected, or unadopted behavior | Keep outside `knowledge/` in changes, intake, or reconstruction | - -A significant product change normally updates both a product concept and its -linked engineering concept. A refactor with unchanged product behavior may -update engineering knowledge only. Do not create an empty counterpart merely -for symmetry. - -## Promotion procedure - -1. Work from the knowledge root. Require and invoke the native QMD skill, use - QMD to locate candidates, and read every selected document directly. -2. Identify the smallest primary Area. Use root product flows, architecture, - repositories, or decisions only when ownership genuinely crosses Areas. -3. Inspect existing lifecycle, provenance, realization, quality receipt, - verification, and decision lineage. -4. When implementation matters, invoke `analyze-with-graphify` in each exact - leaf, then inspect pinned source, tests, contracts, and runtime evidence. -5. Separate accepted intent, observed delivery, alignment, technical - realization, decision history, and uncertainty. Ask the maintainer only for - missing product authority, chronology, ownership, or a material decision. -6. Route product and engineering documents to their specialized skills. Never - reuse one body for both audiences. -7. For a new Area, create its product-facing index from - [the Area template](assets/area-index.md). Add only the typed sibling - collections needed now: `capabilities/`, `use-cases/`, `concepts/`, - `rules/`, `implementation/`, `decisions/`, and `log.md`. -8. Give every concept explicit `view`, `purpose`, and `audience`. Attribute - every material claim with an authoritative source ID and matching footnote. -9. Declare only material semantic edges in `x-wf.relations`; include a - meaningful context and a matching human-visible Markdown link. -10. Create a standalone decision only when the choice is hard to reverse, - surprising without context, or resolves a real tradeoff. Routine - implementation choices and minor wording changes belong in the owning - concept, change ledger, or Area evolution. Author durable decisions from - [the decision template](assets/decision.md). Keep one stable current - decision per lineage; make supersession reciprocal and acyclic. Preserve - approved predecessor bodies. -11. Update the product-facing Area index and its Evolution section when - current behavior changes. Append detailed chronology to the local log. -12. Invoke `verify-knowledge-quality` for every new or materially changed - concept. Do not self-approve a failed, uncertain, unread, or blocked check. -13. Finish content before hashing. Bind the passed quality receipt and normal - verification to the same `wfctl knowledge hash --concept <path>` output. - Normative claims require human verification. The hash reads frontmatter and - body rather than location, so a page drafted under a bundle's `promotion/` - directory is sealed where it is and the seal survives the copy. -14. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update` for - a page that is already in `knowledge/`. Rebuild embeddings only when semantic - retrieval is needed. -15. Return to the originating workflow and record where each page is. A change - bundle keeps its pages under `promotion/` and records them with `wfctl work - promotion <id>`; nothing it wrote is in the corpus, and saying it is promoted - would claim a decision the maintainer has not made. Do not report completion - while any gate fails. - -## Where a page goes before it is knowledge - -Writing into `knowledge/` is the project speaking about itself, and it is a -maintainer decision. Two routes reach it, and they differ: - -| Source | Where the page is written | What puts it in `knowledge/` | -| --- | --- | --- | -| Change bundle | `changes/<state>/<id>/promotion/<destination>` | `wfctl work promote <id>`, on the maintainer's word | -| Reconstruction or intake case | `knowledge/` directly | the case's own promotion, which its closure is waiting for | - -The destination path is the same either way: write the draft at exactly the path -it will occupy, because that is the path it is copied to and the path every link -in it must resolve against. - -## Authority rules - -- Product intent, meaning, rules, and normative decisions require explicit - maintainer authority. -- Existing implementation requires pinned source and direct inspection. - Runtime claims require a fresh receipt when static code is insufficient. -- An absent delivery claim may use a reviewed whole-scope reconstruction - receipt because nonexistent code cannot be pinned. -- Architectural rationale, ownership, contracts, and policy require - maintainer review and contradiction checks against current implementation. -- Historical implementation requires pinned version-control history plus a - reviewed archive or reconstruction receipt. -- External facts require primary sources. - -A rejected proposal remains case-only by default. When repeated proposals -reveal a durable product boundary, ask the maintainer whether the boundary -itself is an accepted non-goal or negative rule. Only that explicitly accepted -rule may become current product knowledge or a decision; the rejected proposal -and its raw origin still remain outside the trust boundary. - -If a concept mixes authority classes, attribute each material claim to the -correct source. A quality receipt checks the writing and evidence match; it -does not create authority. diff --git a/.agents/skills/curate-project-knowledge/agents/openai.yaml b/.agents/skills/curate-project-knowledge/agents/openai.yaml deleted file mode 100644 index 6ad5907..0000000 --- a/.agents/skills/curate-project-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Curate Project Knowledge" - short_description: "Route verified claims into separate knowledge views" - default_prompt: "Use $curate-project-knowledge to route verified claims into product, engineering, and decision knowledge with quality review." diff --git a/.agents/skills/curate-project-knowledge/assets/area-index.md b/.agents/skills/curate-project-knowledge/assets/area-index.md deleted file mode 100644 index 86c1c9b..0000000 --- a/.agents/skills/curate-project-knowledge/assets/area-index.md +++ /dev/null @@ -1,61 +0,0 @@ -# <Area name> - -## Purpose - -Explain the recognizable responsibility or product functionality this Area -provides and why it exists. - -## Who it serves - -Name the people, roles, and neighboring Areas that depend on it. - -## Scope and boundaries - -State what belongs here, what does not, and the important boundaries with -neighboring Areas. - -## Current product behavior - -Summarize current observable behavior in stakeholder language. Keep accepted -intent, available delivery, partial or absent delivery, and known drift -distinct. Link detailed concepts instead of flattening them here. - -## Capabilities - -- Link each capability and state the product outcome it provides. - -## Use cases and flows - -- Link Area-local use cases. -- Link genuinely cross-Area flows from `knowledge/product/flows/`. - -## Rules and outcomes - -- Link material product rules and summarize only the outcome or constraint a - stakeholder needs to understand. - -## Delivery overview - -Summarize which accepted capabilities are available, partial, absent, retired, -or uncertain. Do not present implementation as accepted intent by default. - -## Current decisions - -- Link the stable current decision for each active Area-owned lineage. -- Reach deprecated predecessors through the current decision or Evolution. - -## Evolution - -Explain meaningful changes with enough context to understand what changed, -why, and what it affected. Link full decision records and the local `log.md`; -do not reduce history to bare supersession links. - -## Open questions - -- Link trusted current uncertainties. Raw candidates do not belong here. - -## Engineering details - -- Link Area-owned engineering concepts from `implementation/`. -- Link system-wide architecture and repository ownership where relevant. -- Keep this section link-only; technical explanations live in those documents. diff --git a/.agents/skills/curate-project-knowledge/assets/decision.md b/.agents/skills/curate-project-knowledge/assets/decision.md deleted file mode 100644 index 390516e..0000000 --- a/.agents/skills/curate-project-knowledge/assets/decision.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -type: Decision -title: "<decision title>" -description: "<current decision in one sentence>" -status: draft -view: decision -purpose: decision-history -audience: - - maintainer - - domain-expert - - engineer -decision_id: "<stable-lowercase-id>" -effective_at: "<ISO-8601>" -area: "<primary-area>" -capabilities: [] -authority: - - decision -generated: - by: "<producer>/<version>" - at: "<ISO-8601>" -verified: [] -supersedes: [] -superseded_by: "" -x-wf: - relations: [] - quality: - status: pending -sources: - - id: "<maintainer-decision-id>" - kind: maintainer-decision - resource: "project-change:<change-id>#decision" - title: "<reviewed decision>" - author: "human:<reviewer-id>" ---- - -# Context and problem - -Explain only the context needed to understand why this durable decision -exists.[^maintainer-decision-id] - -# Product decision - -State the exact stakeholder-visible choice and its boundaries in plain -language. - -# Rationale - -Explain why this option was chosen. - -# Alternatives - -List only alternatives that were seriously considered and why they were not -selected. State `None recorded` rather than inventing options. - -# Consequences and tradeoffs - -State benefits, costs, risks, constraints, and accepted tradeoffs. Link -engineering knowledge for technical consequences instead of embedding an -implementation walkthrough. - -# Affected knowledge - -Link every materially affected Area, capability, use case, rule, engineering -concept, or contract. Record non-lineage semantic edges in `x-wf.relations` -with the same target and meaningful context. - -# Transition and migration - -Explain the product transition and link separate engineering migration detail, -or state `Not applicable`. - -# Unresolved questions - -List only questions left open by this decision, or state `None`. - -# Evolution - -Explain what this changes relative to each predecessor. Use project-relative -`knowledge/...` paths in `supersedes` and `superseded_by`; keep reciprocal -links accurate and include matching links to every predecessor and successor. - -[^maintainer-decision-id]: Explicit maintainer approval in the linked decision receipt. - -Before changing `status` to `stable`, invoke `verify-knowledge-quality`, run -`wfctl knowledge hash --concept knowledge/.../<decision>.md`, and bind both -the passed quality receipt and human verification to the returned hash. diff --git a/.agents/skills/curate-project-knowledge/references/knowledge-model.md b/.agents/skills/curate-project-knowledge/references/knowledge-model.md deleted file mode 100644 index bbffae7..0000000 --- a/.agents/skills/curate-project-knowledge/references/knowledge-model.md +++ /dev/null @@ -1,247 +0,0 @@ -# Knowledge model - -## Surfaces and trust - -- `raw/`: append-oriented untrusted input; never evidence. -- `intake/`: Git-frozen raw review records; never current truth or an OKF source. -- `reconstruction/`: source-first baseline and audit receipts at exact revisions. -- `changes/active/`: proposed behavior, living execution agreements, and their checkpoints. -- `changes/archive/`: qualified historical changes and resolved capture receipts. -- `changes/inbox/`: pending non-authoritative captures awaiting triage. -- `knowledge/`: curated OKF v0.2 current knowledge and the default reading surface. -- source repositories: implementation authority at exact revisions. - -QMD retrieves Markdown but never proves truth or coverage. Compiled graphs are -disposable navigation. Graphify navigates source code but is not authority. -Every selected source is read directly. - -All lanes converge through one promotion gate: extract atomic candidates, -verify each against its proper authority, obtain maintainer adjudication for -normative or ambiguous meaning, update the smallest coherent current concepts, -verify quality, and validate the bundle. - -## One truth, multiple views - -Product and engineering documents are linked views of the same project, not -independent truths. - -### Product view - -Use for vision, Areas, capabilities, use cases, cross-Area product flows, -domain concepts, product rules, delivery summaries, and stakeholder evolution. -It answers what the product provides, who it serves, how it behaves now, which -rules and exceptions apply, and whether it is available. - -Declare: - -```yaml -view: product -purpose: current-behavior -audience: [stakeholder, maintainer, domain-expert] -``` - -Product bodies contain no implementation detail. Their `Engineering details` -section contains links only. - -### Engineering view - -Use for implementation, architecture, repositories, contracts, data and -control flow, runtime behavior, operations, and technical constraints. It -answers how current product behavior is realized and verified. - -Declare: - -```yaml -view: engineering -purpose: technical-realization -audience: [engineer, operator, maintainer] -``` - -Engineering documents link product meaning and never infer accepted intent -from code. - -### Decision, reference, and uncertainty views - -- Decisions use `view: decision`, `purpose: decision-history`, and include the - maintainer audience. -- Primary external context uses `view: reference`, - `purpose: external-context`. -- Trusted live questions use `view: uncertainty`, `purpose: open-question`. - -Proposed or rejected ideas do not use a current knowledge view. - -Create a standalone decision only when the choice is hard to reverse, -surprising without context, or resolves a real tradeoff. Keep routine local -choices in the owning concept, change ledger, or Area evolution. A repeated -rejection may expose a durable non-goal, but only an explicit maintainer -decision promotes that negative rule; rejected proposals remain case-only. - -## Human information architecture - -- `knowledge/index.md`: progressive project entry point. -- `vision/`: accepted project purpose, outcomes, principles, and non-goals. -- `areas/`: primary durable product or functional decomposition. -- `product/`: concise users, outcomes, Areas, and genuinely cross-Area flows. -- `architecture/`: cross-Area technical realization. -- `decisions/`: only genuinely cross-Area decision records. -- `repositories/`: technical ownership and integration boundaries. -- `uncertainties/`: trusted unresolved current questions. -- `references/`: primary external context. - -Each `areas/<area>/index.md` is the main stakeholder page. It links typed -sibling collections: - -```text -areas/<area>/ -├── index.md -├── capabilities/ # product -├── use-cases/ # product -├── concepts/ # product/domain -├── rules/ # product -├── implementation/ # engineering -├── decisions/ # decision history -└── log.md # local chronology -``` - -Do not nest implementation and decisions under a capability merely because -they support it. Link them. Subdivide a typed collection only when its own -size requires it. - -Use root collections only for honest project-wide ownership. When one Area is -primary, store the artifact there and link it from affected Areas. A bounded -context is a proven technical model and language boundary, not another word -for Area. - -## Product intent and realization - -Document lifecycle and product delivery are independent: - -```yaml -realization: - intent: accepted - delivery: verified - alignment: aligned - assessed_at: 2026-07-28T12:00:00Z -``` - -- Curated intent is `accepted` or `superseded`. -- Delivery is `absent`, `partial`, `implemented`, `verified`, `retired`, - `unknown`, or `not-applicable`. -- Alignment is `aligned`, `drifted`, `unknown`, or `not-applicable`. - -Concrete delivery requires implementation authority. Concrete alignment -requires both product and implementation authority. Code proves observed -delivery, never accepted intent or correctness. - -## Authored relations and compiled navigation - -Every concept declares `x-wf.relations`. Add only material relations, give -each a meaningful context, and repeat its target as a normal Markdown link. -Supported kinds are `supports`, `governed-by`, `implemented-by`, `depends-on`, -`affects`, `conflicts-with`, and `related-to`. - -Area ownership and decision lineage have dedicated metadata and generated -edges. Stable concepts remain reachable from `knowledge/index.md`. -`wfctl knowledge build` compiles these explicit statements into ignored -navigation artifacts; it infers no truth. - -## Strict profile over OKF - -Every concept requires: - -- explicit lifecycle, view, purpose, audience, generation, and authority; -- non-empty claim-level authoritative sources with matching footnotes; -- explicit authored relations and valid human-visible links; -- current verification for stable content; -- a current semantic quality receipt for stable content; -- human verification for normative authority; -- explicit deprecation destination or reason; -- no raw or intake reference. - -Path and view must agree: - -- product: `vision/`, `product/`, and Area `capabilities/`, `use-cases/`, - `concepts/`, or `rules/`; -- engineering: `architecture/`, `repositories/`, and Area `implementation/`; -- decision: root or Area `decisions/`; -- reference: `references/`; -- uncertainty: `uncertainties/`. - -The validator rejects code and implementation sections in product documents -and requires their stakeholder sections. It requires technical sections in -engineering documents. Deterministic checks cannot prove semantic truth, so -`verify-knowledge-quality` reads the full evidence and records a -content-hash-bound review. - -## Quality receipt - -After semantic review, record: - -```yaml -x-wf: - relations: [] - quality: - status: passed - by: workflow-agent/1 - at: 2026-07-28T12:00:00Z - content_hash: "<wfctl knowledge hash output>" - checks: - - factuality - - audience-fit - - abstraction - - completeness - - delivery-state - axes: - authority-truth: - status: passed - by: workflow-agent/1 - at: 2026-07-28T12:00:00Z - content_hash: "<same hash>" - reader-communication: - status: passed - by: workflow-agent/1 - at: 2026-07-28T12:00:00Z - content_hash: "<same hash>" -``` - -The material hash excludes `verified` and `x-wf.quality`, allowing both -receipts to bind the exact authored content without self-reference. Any other -material edit changes the hash and invalidates both receipts. The quality -receipt records a review; it creates no authority. Its two axes review -authority/truth and reader communication independently. - -## Authority by claim - -- Intent, product meaning, architecture rationale, ownership, contracts, - policy, and decisions require maintainer authority. -- Existing implementation requires pinned source code. -- Absent delivery may use a reviewed whole-scope reconstruction receipt. -- History requires pinned version-control evidence plus a reviewed archived - change or reconstruction receipt. -- External facts require primary sources. - -Pinned code resources use -`git:<repository>@<40-character-commit>#<path>[:<symbol>]`. -Reviewed changes use `project-change:<id>#<section>`. -Reviewed reconstruction decisions use -`project-reconstruction:<case-id>#<candidate-id>`. - -Authority is claim-specific. Repetition, recency, search rank, and agent -confidence do not create authority. - -## Current truth and evolution - -Keep current meaning at one stable path. A changed decision creates a successor -record and deprecates predecessors through reciprocal lineage. Do not version -whole Areas. - -The product Area index leads with current behavior. Its Evolution section -summarizes what changed, why, and what it affected. Full decision records keep -context, exact choice, rationale, alternatives, consequences, transition, open -questions, and lineage. Area logs provide local chronology without flattening -hundreds of decisions into one file. - -Canonical domain language belongs with the owning Area concept rather than a -flattened global glossary. Record the preferred term, definition, contextual -boundary, accepted aliases, and names to avoid. Proposed terminology stays in -the active change record until product authority accepts it. diff --git a/.agents/skills/error-handling/SKILL.md b/.agents/skills/error-handling/SKILL.md deleted file mode 100644 index 3e2282f..0000000 --- a/.agents/skills/error-handling/SKILL.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -name: error-handling -description: Use whenever work adds, changes, reviews, or debugs thrown errors, rejected promises, catch or finally blocks, retries, cancellation, fallbacks, recovery, error stacks, cause chains, lost call sites, async or RPC origins, source maps, global error listeners, React or route error boundaries, TanStack Query failure policy, incident reporting, expected domain outcomes, or decides which layer owns a failure. Trigger even when the request only mentions an unhandled rejection, swallowed error, duplicate report, misleading stack trace, crash fallback, mutation failure, background refetch failure, Sentry or OpenReplay exception capture, or whether an error should propagate. ---- - -# Error Handling - -Handle a failure once, at the narrowest boundary that can make the required -decision. Keep propagation, reporting, recovery, and user presentation separate -so that one failure does not become several logs, incidents, and messages. - -Error handling is a control-flow concern. Logging records diagnostic facts; -user feedback presents an outcome. Neither one automatically means the failure -was handled. - -## Trace The Existing Flow First - -Before changing an error path: - -1. Find where the value originates and whether it is thrown, rejected, or - returned as a typed result. -2. Trace every layer that catches, logs, reports, retries, converts, or presents - it. -3. Inspect global listeners, error boundaries, and TanStack Query cache - callbacks that may already observe it. -4. Identify the boundary that owns recovery and the boundary that owns user - feedback. -5. Preserve the repository's established reporter and provider integrations. - -Do not add a local `catch` until this trace proves what decision belongs there. - -## Classify The Outcome Before Handling It - -Do not treat every non-happy path as the same failure: - -- a successful value needs no error path; -- an expected domain outcome should remain a typed result when the contract - models it as one; -- cancellation or superseded work usually needs cleanup, not an incident; -- recoverable degradation may justify a warning and fallback; -- an operation failure may need local recovery, reporting, or both; -- an unrecoverable render or initialization failure needs a recovery boundary. - -Do not invent an `Error` subclass merely to represent a non-failure such as -`pending`, `queued`, or `requiresApproval`. Prefer the generated or -schema-derived discriminated result. Preserve an existing typed-error control -flow when the project deliberately uses one, but do not generalize it into a -new application-wide convention. - -## Give Each Boundary One Job - -Low-level transports, API functions, and reusable utilities normally preserve -and propagate failures. They do not choose toast copy, navigation, or a page -fallback. - -A boundary may catch when it can: - -- recover or select a fallback; -- retry with an explicit policy; -- translate a known domain outcome into another established contract; -- attach context required by an incident reporter; -- present feedback owned by that interaction; -- perform cleanup in `finally`; -- terminate propagation intentionally. - -If none applies, let the failure propagate. - -## Avoid Catch, Log, And Rethrow - -Do not catch only to log and rethrow: - -```ts -// Avoid: the caller, query cache, or global boundary may report it again. -try { - return await loadWorkspace(workspaceId) -} catch (error) { - log.error("Workspace load failed", error, { workspaceId }) - throw error -} -``` - -Prefer direct propagation: - -```ts -return loadWorkspace(workspaceId) -``` - -Catch and rethrow only when adding information that cannot be supplied at a -higher boundary. Prefer `cause` or the repository's established typed contract; -do not flatten the original value into a generic message. - -Never swallow silently. An intentionally ignored failure must be demonstrably -expected. Add a short comment or a `warn` with safe context when the reason is -not obvious. - -## Preserve The Failure Origin - -A `try`/`catch` does not erase an existing error stack by itself. The origin is -usually lost when code replaces the value with a new error, converts it to a -string, mutates its stack, reports only the catch location, or crosses an -async/process boundary without carrying diagnostic origin separately. - -- Rethrow the same value when no new contract is required. -- If a new error genuinely adds boundary meaning, preserve the received value - as `cause`; do not pretend the wrapper stack is the original failure stack. -- Never append consumer or call-site text to `error.stack`. Keep - `errorStack`, `cause`, and an optional `originStack` as separate facts. -- Capture an origin before starting work whose later stack cannot lead back - across a timer, event, Query execution, worker, native module, or RPC call. -- Report native or host failures in the runtime where the original error still - exists. Correlate runtimes with safe request or operation identifiers instead - of reconstructing an error in the receiving runtime. -- Treat source maps and symbolication as part of the production error path, not - optional build decoration. - -Read `references/trace-preservation.md` whenever changing catches, wrappers, -async scheduling, global reporting, native boundaries, or stack diagnostics. - -## Preserve Errors As Received - -Keep transport and domain error identity, status, code, details, and cause -available to the owner that understands them. - -Do not introduce: - -- a universal application error class; -- recursive error normalization or serialization; -- registries that must change for every new error type; -- message-string matching when a typed field exists; -- catch-all conversion merely to satisfy a local helper. - -Create an `Error` fallback only at a boundary that requires an actual `Error` -instance, such as an exception-reporting SDK or render boundary, and retain the -original value as `cause` when useful. - -An established privacy or redaction adapter may need a safe representation for -one specific sink. Keep it at that sink. If legacy code must produce a redacted -replacement `Error`, verify that it does not replace the only surviving failure -stack: retain the original locally when safe and carry its unchanged standard -stack explicitly in the sink-local error or record. Do not promote that -exception into a generic error conversion pipeline. - -## Make Incident Reporting Explicit - -Reporting means sending a failure to an incident or diagnostic backend. It is -not synonymous with logging or showing feedback. - -- Report once, at the boundary with the best safe diagnostic context. -- Keep provider SDKs behind the repository's reporting facade or - provider-owned logger transport. -- Do not turn every `logger.error()` into an incident unless the repository - explicitly defines the error level that way. When it does, reporting emits - one logger record and must not call the provider separately. -- Do not make `showError()` secretly report through a boolean option. -- Avoid reporting expected cancellation, validation failures, authorization - outcomes, or other explicitly handled states unless project policy requires - it. -- Keep identifiers and operational metadata; exclude secrets, credentials, - raw payloads, and unnecessary personal data. - -When both local logging and incident capture are needed, ensure they represent -one intentional flow rather than two independent observers reporting the same -failure. - -## Use Global Boundaries As Backstops - -Initialize global rejection and uncaught-error listeners once at the runtime -composition root. They catch failures that escaped normal ownership; they are -not a replacement for local recovery. - -Treat these listeners as terminal observers. Pass the supplied error or -rejection reason into the configured logger with automatic origin capture -disabled. When an incident provider is a logger transport, this one record must -fan out to both persistence and incident capture; do not call the provider -again from the listener. - -Use React, route, or application error boundaries to: - -- report otherwise-unhandled render failures; -- replace a broken subtree with stable fallback UI; -- offer an appropriate reset, retry, reload, or navigation action; -- isolate the smallest useful region when recovery can remain local. - -Do not use an error boundary for event-handler or awaited action failures that -the interaction boundary can handle directly. - -## Treat Retry As Product Behavior - -Retry only when the operation is safe to repeat and the policy is explicit. -Consider idempotency, attempt limits, delay, cancellation, offline behavior, -and whether the user should remain in control. - -Do not add retries merely to hide an unknown failure. Do not combine automatic -retry at several layers. One owner must decide when attempts stop and what -recovery becomes visible. - -## Coordinate TanStack Query Deliberately - -TanStack Query can observe a failure at the query or mutation function, cache, -hook, call site, and error boundary. Choose one technical reporting path and one -presentation owner. - -Read `references/tanstack-query.md` whenever work touches Query or Mutation -failure behavior, `mutateAsync`, cache callbacks, background errors, -`throwOnError`, retry, or duplicate reporting. - -## Keep User Presentation Separate - -After handling policy is decided, delegate presentation to **user-feedback**: - -- field or form errors; -- inline region errors; -- toasts, banners, dialogs, and alerts; -- success, warning, and informational outcomes; -- retry or recovery controls visible to the user. - -The same catch boundary may call the reporter and the feedback layer when it -truly owns both decisions, but the helpers themselves remain independent. - -## Verify The Result - -Before finishing: - -- trace one failure from origin to its terminal owner; -- confirm it is not logged or reported twice; -- confirm expected cancellation and domain outcomes are not incidents; -- confirm low-level code does not own product copy or UI; -- confirm the original typed error remains available where needed; -- confirm no catch, wrapper, reporter, or transport overwrites the original - error stack; -- confirm any async or cross-runtime origin was captured before the boundary and - kept separately; -- confirm production stack artifacts match the exact release or update; -- confirm retry has one owner and a stop condition; -- confirm global listeners and boundaries initialize once; -- confirm incident context contains no secrets; -- confirm the user receives one appropriate presentation, if any. - -## Related Skills - -- External transports, generated error contracts, and query or mutation - ownership → **api-integration**. -- Diagnostic records, child context, persistence, and telemetry transports → - **logging**. -- Toasts, inline messages, dialogs, fallbacks, and recovery copy → - **user-feedback**. -- Form validation and server field errors → **forms**. -- Component and async-region rendering → **components**. -- Native exception boundaries and privileged provider wiring → - **native-integration**. -- Placement of reporting modules and boundaries → **file-structure**. diff --git a/.agents/skills/error-handling/agents/openai.yaml b/.agents/skills/error-handling/agents/openai.yaml deleted file mode 100644 index 6ab0673..0000000 --- a/.agents/skills/error-handling/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Error Handling" - short_description: "Error ownership, reporting, and recovery" - default_prompt: "Use $error-handling to design or review error propagation, catch boundaries, reporting, and recovery behavior." diff --git a/.agents/skills/error-handling/references/tanstack-query.md b/.agents/skills/error-handling/references/tanstack-query.md deleted file mode 100644 index 278ee2c..0000000 --- a/.agents/skills/error-handling/references/tanstack-query.md +++ /dev/null @@ -1,195 +0,0 @@ -# TanStack Query Failure Flow - -Use this reference when changing query or mutation error propagation, -reporting, retry, feedback, or Error Boundary behavior. - -## Keep The Query Function Honest - -Let a query or mutation function reject with the original transport or domain -error. Do not resolve a failure as ordinary data and do not convert every error -into a generic application type. - -The API module owns transport behavior and canonical cache effects. It does not -own page-specific feedback. - -## Choose One Reporting Observer - -`QueryCache` and `MutationCache` callbacks can provide one application-wide -diagnostic observer: - -```ts -const queryClient = new QueryClient({ - queryCache: new QueryCache({ - onError(error, query) { - if (isExpectedCancellation(error)) return - - reportError(error, { - captureOrigin: false, - module: "query", - context: { - queryFamily: String(query.queryKey[0] ?? "unknown"), - }, - }) - }, - }), -}) -``` - -This is a policy option, not a mandatory pattern. Reporting every cache error -without classification creates noise from cancellation, background refetch, -offline behavior, and expected authorization or domain outcomes. - -`captureOrigin: false` is deliberate in a declarative Query observer: the -query's rejection stack is primary, while a newly captured cache-callback stack -only identifies observation. A Mutation observer may instead receive an -earlier `originStack` captured per execution as shown below. - -Do not serialize an entire query key, mutation variables, or transport payload -into reporting context. Select only the safe identifiers needed for diagnosis. - -When cache-level reporting exists, do not report the same failure again from a -component merely because it also presents feedback. - -### Do Not Forge A Combined Stack - -A Query or Mutation error stack usually identifies the transport or domain -failure but may not identify the component or interaction that started the -operation. Keep those facts separate. - -Do not mutate the shared error: - -```ts -// Avoid: retries, observers, and reporters now see a modified error object. -error.stack += `\n--- used in ---\n${callSite}` -``` - -If a consumer origin matters, capture it before the asynchronous execution and -pass it as diagnostic metadata: - -```ts -const origin = captureTraceOrigin() - -try { - await updateProject.mutateAsync(input) -} catch (error) { - reportError(error, { - originStack: origin.stack, - context: { projectId }, - }) -} -``` - -This call-site owner replaces cache-level reporting for that same failure; the -cache may still own retry and canonical mutation lifecycle. Do not create two -incident reports. - -`meta` is suitable for stable mutation ownership or a registration stack -captured when options or a hook are created. That identifies the consumer -definition, not necessarily the later `mutate()` or `mutateAsync()` invocation. -Name it honestly, for example `registrationStack`, and do not present it as the -exact invocation stack. - -If an application requires one global MutationCache reporter with the actual -`mutate()` or `mutateAsync()` invocation chain, capture synchronously in the -global `onMutate` callback and associate the origin with that Mutation instance: - -```ts -const mutationOrigins = new WeakMap<object, TraceOrigin>() - -const mutationCache = new MutationCache({ - onMutate(_variables, mutation) { - mutationOrigins.set(mutation, captureTraceOrigin()) - }, - onError(error, _variables, _onMutateResult, mutation) { - reportError(error, { - originStack: mutationOrigins.get(mutation)?.stack, - context: { - mutationFamily: String( - mutation.options.mutationKey?.[0] ?? "unknown", - ), - }, - }) - }, - onSettled( - _data, - _error, - _variables, - _onMutateResult, - mutation, - ) { - mutationOrigins.delete(mutation) - }, -}) -``` - -Capture at the start of `onMutate`, before returning or awaiting anything. In -current TanStack Query execution, that callback is entered from the imperative -mutation call before mutation work crosses its asynchronous boundary, so its -raw stack still includes the consumer chain. The WeakMap keeps concurrent -Mutation instances isolated and releases settled entries. - -Verify this behavior against the installed TanStack Query version because -callback signatures and execution details are library contracts that may -change. A restored or resumed persisted mutation has no live caller origin; do -not invent one. - -Do not put the origin in mutation variables, use one mutable "latest origin" -slot, mutate the error, or parse a fixed number of stack lines. - -## Separate Background Failure From Empty Failure - -A background refetch may fail while usable cached data remains visible. Do not -replace that data with a full error screen or emit a global toast automatically. -Choose a subtle stale or retry indication when the product needs one. - -An initial query with no usable data may render an inline error region with a -retry action. Follow the async rendering waterfall from **components** and the -presentation rules from **user-feedback**. - -## Understand Mutation Control Flow - -`mutateAsync()` returns a promise and rejects when `mutationFn` rejects. An -ordinary `try`/`catch` can therefore classify a submit or action failure: - -```ts -try { - await updateProject.mutateAsync({ projectId, name }) - showSuccess("Project updated") -} catch (error) { - showError(error, { - fallbackMessage: "Unable to update the project", - }) -} -``` - -`throwOnError` controls whether a stored mutation error is propagated during -render to an Error Boundary. It is not required for `mutateAsync()` to reject. - -The callback-based `mutate()` does not return an awaitable failure. Use its -callbacks when that style already owns the interaction; do not wrap it in -`try`/`catch` and expect the asynchronous error there. - -## Keep Lifecycle Ownership Stable - -- Keep canonical invalidation and cache updates in reusable mutation options - owned by **api-integration**. -- Keep interaction-specific success and failure feedback at the call site. -- Do not spread mutation options and silently replace their lifecycle - callbacks. -- Do not duplicate retry between TanStack Query, a transport interceptor, and - the component. -- Use Error Boundary propagation only for failures the local interaction - cannot or should not recover from. - -## Test The Policy - -Cover at least: - -- initial failure without data; -- background failure with cached data; -- expected cancellation; -- mutation rejection handled at the call site; -- repeated or concurrent mutations without origin cross-contamination; -- retry without repeated mutation of the same error stack; -- one technical report for one failure; -- retry exhaustion and recovery. diff --git a/.agents/skills/error-handling/references/trace-preservation.md b/.agents/skills/error-handling/references/trace-preservation.md deleted file mode 100644 index a975c17..0000000 --- a/.agents/skills/error-handling/references/trace-preservation.md +++ /dev/null @@ -1,180 +0,0 @@ -# Error Trace Preservation - -Use this reference when a catch, wrapper, async boundary, reporter, or native -call makes a failure point to infrastructure instead of its real origin. - -## Contents - -- [Catch Is Not The Loss](#catch-is-not-the-loss) -- [Propagation And Wrapping](#propagation-and-wrapping) -- [Async Origins](#async-origins) -- [Process And Native Boundaries](#process-and-native-boundaries) -- [Reporting](#reporting) -- [Framework Boundaries](#framework-boundaries) -- [Verification](#verification) - -## Catch Is Not The Loss - -An existing `Error` normally keeps the stack captured when it was created. -Merely entering a `catch` does not replace it: - -```ts -try { - return await loadProject() -} catch (error) { - throw error -} -``` - -This catch is still unnecessary when it makes no decision, but it does not by -itself reset an `Error` stack. - -The destructive versions are: - -```ts -catch (error) { - throw new Error(String(error)) -} - -catch (error) { - reportError(new Error("Project failed")) -} - -catch (error) { - error.stack += `\n${new Error().stack}` -} -``` - -They replace identity, discard typed fields or cause, or corrupt the original -stack. - -## Propagation And Wrapping - -Propagate the received value unchanged unless a boundary owns a real contract -translation. - -When a wrapper adds essential meaning, retain the original: - -```ts -try { - await storage.open(projectId) -} catch (error) { - throw new Error("Opening project storage failed", { - cause: error, - }) -} -``` - -The wrapper stack and cause stack describe different layers. Do not concatenate -them into one string. Let the established incident provider represent the cause -chain when it supports it; otherwise retain the original cause locally and add -small safe context explicitly. - -Do not create a wrapper only to change wording for a log or user message. Logger -messages and user feedback can add context without replacing the failure. - -## Async Origins - -An error stack describes where the failure occurred. It may not show who -scheduled the work after a timer, event, queue, Query execution, worker, or RPC -boundary. - -Capture a supplemental origin synchronously before crossing that boundary: - -```ts -const origin = captureTraceOrigin() - -runInBackground() - .catch(error => { - reportError(error, { - originStack: origin.stack, - context: { operationId }, - }) - }) -``` - -Keep the origin outside the error object. Capture one origin per execution so -parallel work cannot overwrite another operation's diagnostic. - -Do not capture inside the later `catch` and label it as the operation origin. It -only identifies the observer. - -## Process And Native Boundaries - -Do not expect an `Error` instance to preserve class identity, non-enumerable -fields, cause, and stack through browser workers, Electron IPC, native modules, -or another RPC implementation. - -Choose ownership before transport: - -- report a host/native failure in the host/native runtime while the original - error exists there; -- report a renderer/JavaScript failure in that runtime before sending a - persistent log record; -- send minimal standard diagnostic strings only when another runtime must - persist or display them; -- use a request or operation identifier to correlate the two runtime records. - -Never recreate a received string as `new Error(receivedMessage)` and present its -receiver-side stack as the remote failure origin. - -## Reporting - -Pass the original error to the incident reporter whenever it accepts the value. -Attach safe module, operation, request, and supplemental origin information -through the provider's scope or context API. - -If a reporter requires an `Error` but the thrown value is not one, create a -fallback only at that reporter boundary: - -```ts -const reportable = - error instanceof Error - ? error - : new Error(message, { cause: error }) -``` - -That fallback stack identifies the conversion boundary. Do not describe it as -the original failure location. - -Avoid global `Error.prepareStackTrace` overrides and stack-string rewriting. -They are engine-specific and can interfere with source-map and incident -provider processing. - -An established redaction adapter is another replacement boundary. If it creates -a safe `Error` instance or record for one sink, preserve the received error in -the local flow when safe and copy its unchanged standard stack explicitly into -that sink-local diagnostic shape. A newly constructed redacted error otherwise -points to the sanitizer, which makes the privacy layer look like the failure -origin. - -## Framework Boundaries - -- TanStack Query cache callbacks are observers. Preserve the rejection and use - explicit ownership metadata; read `tanstack-query.md` for exact invocation - origins. -- Disable automatic logger-origin capture in observers that have no earlier - operation origin. Their stack is still available when intentionally needed, - but it must not be mislabeled as the caller. -- React error boundaries receive an error stack and a component stack. Preserve - both as separate diagnostic fields. -- Global `error` and `unhandledrejection` listeners are terminal backstops. Use - the supplied `Error` or rejection reason; do not replace it merely to add the - words "Unhandled error". -- A retry is another execution, not permission to append another section to the - same error stack. - -## Verification - -For each changed flow, record: - -1. where the original error is created; -2. which layer first catches it; -3. which layer owns recovery or reporting; -4. which discontinuity requires a supplemental origin; -5. which runtime retains the original error; -6. how production frames map to the exact shipped artifact. - -Force the same failure through retries and concurrent executions. Confirm that -the original stack remains byte-for-byte unchanged and each execution retains -its own origin. diff --git a/.agents/skills/explore-project-knowledge/SKILL.md b/.agents/skills/explore-project-knowledge/SKILL.md deleted file mode 100644 index 4e93f28..0000000 --- a/.agents/skills/explore-project-knowledge/SKILL.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -name: explore-project-knowledge -description: Guide a person through a project's current product knowledge without requiring them to know its Areas, capabilities, terminology, repository layout, document paths, or implementation. Use for broad newcomer questions such as what this project is, why it exists, what it can do today, what is accepted but unavailable or unknown, where to start, or what its main directions are; for follow-ups that explore one Area, capability, use case, flow, rule, delivery state, or product decision; and whenever a product manager, client, maintainer, or domain expert needs a progressive nontechnical explanation rather than knowledge authoring. Remain read-only, reveal detail gradually, and never turn a question into curation or source work without explicit need. ---- - -# Explore Project Knowledge - -Act as a product guide for someone who may not know what to ask yet. Discover -the available reading path yourself and reveal it gradually. - -Read [the exploration contract](references/exploration-contract.md) before the -first broad discovery request in a session. - -## Resolve the knowledge surface - -1. If the current repository has the `knowledge` profile, work from it. -2. If the current repository is a leaf, read `.workflow/config.json`, resolve - its configured knowledge repository, and answer from that repository. -3. Require and invoke the native QMD skill. Check `qmd status`, and when it - reports documents pending embedding run `qmd embed` first: without vectors - the search degrades to lexical BM25 over the newest material. Then search only - the `knowledge` collection. If the native skill, CLI, or project index is - unavailable, invoke `setup-workflow-environment`; do not substitute grep or - pretend discovery was complete. -4. Answer from the documents first. Do not run `wfctl check` or - `wfctl knowledge validate` merely to answer a question: exploration is the - least expensive path, and both commands are diagnostics, not reading. Run - `wfctl knowledge validate` only when a document you read looks internally - inconsistent, a link you need is broken, or the reader asks how trustworthy - the map is. When it does fail, give only the bounded trustworthy orientation - still supported by readable current documents, state that the map is - incomplete, and offer a separate repair or audit. -5. Start with `knowledge/index.md` and the reachable Area indexes. Use QMD and - `.workflow/current/knowledge-graph.json` to find candidate paths, then read - every selected Markdown document directly. The compiled graph is a - navigation cache: when a path it suggests does not exist, fall back to - direct reading rather than trusting the graph or rebuilding it mid-answer. -6. Treat curated knowledge as the answer surface, subject to its lifecycle, - provenance, verification, realization, and uncertainty. Retrieval results - and generated graphs are navigation, not evidence. - -## Choose the reader's current level - -- **Discovery:** The reader does not know the project. Explain its purpose, - intended audience, current product shape, major directions, delivery - overview, and important unknowns. -- **Area exploration:** Explain one product direction, the outcomes it owns, - major capabilities and flows, current delivery, governing rules, and useful - next branches. -- **Focused explanation:** Explain one capability, use case, flow, rule, or - product decision: current behavior first, then conditions, exceptions, - delivery, and meaningful evolution. -- **Technical or historical deep dive:** Hand routing back to - `operate-project-knowledge` so engineering realization or full decision - lineage remains a separately labeled answer. - -Do not force the reader to choose a level or use workflow terminology. Infer -the narrowest honest level from ordinary language and continue naturally. - -## Present progressive disclosure - -1. Lead with the current product answer, not file structure or methodology. -2. On discovery, present a compact project map rather than flattening every - concept. Group a large corpus into a manageable set of recognizable - directions. -3. For every item, distinguish verified/current, partial, accepted but absent, - retired, and unknown. Do not turn missing delivery into missing intent or - vice versa. Proposed plans are outside current knowledge; route an explicit - roadmap question through `operate-project-knowledge` and label it separately. -4. Use stakeholder and domain language. Explain an internal term before using - it as navigation. -5. End with three to five concrete follow-up directions derived from the - knowledge actually found. Let the reader choose what to open next. -6. Ask at most one question when a useful overview can still be given. Ask - earlier only when the request could refer to materially different projects - or product meanings. -7. Keep code, repositories, paths, schemas, APIs, workflow commands, source - metadata, and quality machinery out of the answer unless the reader asks. - -## Stay read-only - -- Do not create or edit knowledge, changes, intake, reconstruction, indexes, or - source code merely because the reader asked a question. -- Do not invoke curation or quality verification for an explanation alone. -- If current knowledge is missing, stale, contradictory, or too weak to answer, - explain the exact product-level limitation and offer the appropriate next - action: knowledge audit, source verification, reconstruction, raw intake, or - maintainer adjudication. -- Perform that action only when the user asks to continue or when their - original request explicitly required current verification or repair. -- If exploration exposes a likely documentation defect, report it separately; - do not silently repair it. - -When available, return control to `operate-project-knowledge` whenever the -request changes from understanding into auditing, verification, authoring, or -decision-making. From a leaf, follow the leaf workflow when the request becomes -implementation. diff --git a/.agents/skills/explore-project-knowledge/agents/openai.yaml b/.agents/skills/explore-project-knowledge/agents/openai.yaml deleted file mode 100644 index 6e08490..0000000 --- a/.agents/skills/explore-project-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Explore Project Knowledge" - short_description: "Guide people through current project knowledge" - default_prompt: "Use $explore-project-knowledge to help me understand this project from a product perspective." diff --git a/.agents/skills/explore-project-knowledge/references/exploration-contract.md b/.agents/skills/explore-project-knowledge/references/exploration-contract.md deleted file mode 100644 index e383a7a..0000000 --- a/.agents/skills/explore-project-knowledge/references/exploration-contract.md +++ /dev/null @@ -1,77 +0,0 @@ -# Product exploration contract - -## Reader assumption - -Assume the reader may know only that a project exists. They are not expected to -know its taxonomy, feature names, implementation, history, or the wording used -inside the knowledge base. - -The agent owns discovery. The reader owns curiosity and product authority. - -## Response levels - -### Discovery - -Answer: - -1. What is this project for? -2. Who benefits from it? -3. What are its major product directions? -4. What works today? -5. What is partial, accepted but absent, retired, or unknown? -6. Where could the reader go next? - -Keep the first response scannable. Prefer a short orientation plus three to -five meaningful branches over an exhaustive catalog. - -### Area exploration - -Answer: - -1. What outcome does this direction own? -2. Who uses or depends on it? -3. Which major capabilities and flows belong to it? -4. Which rules and boundaries shape it? -5. What is its current delivery state? -6. Which questions or decisions remain open? - -### Focused explanation - -Lead with current behavior. Then explain rules, exceptions, delivery, examples, -and only the evolution necessary to understand the present. - -## Conversation behavior - -- A broad question receives a useful answer before any clarifying question. -- Suggested follow-ups use names and descriptions the reader has just seen. -- Each follow-up narrows one level unless the reader asks to compare multiple - directions. -- Do not expose document paths, metadata, retrieval queries, or agent workflow - in the normal answer. -- Do not offer an engineering deep dive as the only next step. Keep product - exploration useful to nontechnical readers. -- Do not create knowledge while answering. A discovered gap becomes an - explicit optional next action. -- Do not search proposals or active changes during ordinary current-product - discovery. A roadmap question is a separate route and must remain labeled as - future work. - -## Failure conditions - -Fail the exploration when it: - -- asks the reader to name an Area or capability before showing what exists; -- starts with code, architecture, repositories, or file navigation; -- presents a flat inventory with no product hierarchy; -- hides delivery or uncertainty; -- treats planned or accepted-but-absent behavior as available; -- overwhelms the first answer with every rule and historical decision; -- modifies project state without a separate request; -- invents a complete project map from sparse or unverified knowledge. - -## Evaluation discipline - -Test discovery with natural prompts that do not reveal the expected taxonomy or -rubric. Keep assertions hidden from the tested agent. Test focused conformance -separately only after the exploration itself has surfaced a real Area or -capability name. diff --git a/.agents/skills/file-structure/SKILL.md b/.agents/skills/file-structure/SKILL.md deleted file mode 100644 index d4dd483..0000000 --- a/.agents/skills/file-structure/SKILL.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: file-structure -description: Use whenever work creates, moves, renames, deletes, splits, merges, or relocates files or directories; introduces a module or folder; adds or expands barrel files; changes which files are public or private through entrypoints or package exports; or requires deciding where code should live. Do not use for content-only edits that preserve existing file placement. ---- - -# File Structure - -Keep the source tree aligned with real ownership. Apply this skill before making -structural changes, not after files have already been placed. - -## Inspect Before Placing - -Read the affected tree before deciding where anything belongs: - -1. Find the nearest existing files with the same responsibility. -2. Inspect local naming, co-location, entrypoint, test, and export conventions. -3. Trace current consumers and package exports before moving or hiding a file. -4. Prefer the repository's coherent existing convention. Do not introduce a - parallel structure merely because another project uses it. -5. If nearby conventions conflict, follow the boundary that best matches actual - ownership and keep the change scoped. Do not reorganize unrelated code. - -## Apply Recursive Encapsulation - -Treat every directory that represents a module as the same recursive structure: - -- its entrypoint is the public boundary seen by consumers outside that directory; -- files owned only by that module live inside the directory, behind the boundary; -- an owned child may become a module with its own entrypoint and private - implementation; -- repeat the same public-outside, private-inside rule at every depth. - -This is about visibility and ownership, not file kind. Apply it equally to -components, hooks, schemas, services, adapters, formatters, fixtures, tests, -state, and any other implementation. - -In this model, consumers sit outside or above the boundary they consume, while -owned implementation goes inward or below it. "Above" and "below" describe the -ownership tree; they do not require every consumer to be located in a literal -parent directory. - -Place every file at the narrowest boundary that contains all current consumers: - -- one file or module only: keep it inside that owner's boundary; -- several files in one feature: keep it at that feature boundary; -- sibling features or pages: place it at their nearest shared owner; -- unrelated features or packages: promote it only when multiple real consumers - require the same contract or behavior. - -Do not promote code for hypothetical reuse. The second real use is evidence to -reconsider placement, not an automatic reason to create a global abstraction. -When consumers change, move the file to their new narrowest common owner instead -of exporting a private path merely to avoid relocation. - -Keep supporting files near the module they verify or support unless the -repository has a stronger established convention. - -## Name Child Folders from Their Context - -Name an owning module by its capability, domain, or responsibility. Inside that -owner, use the conventional role folder that best explains the children: -`components/`, `hooks/`, `schemas/`, `services/`, `adapters/`, `formatters/`, -`fixtures/`, `tests/`, or the repository's established equivalent. - -These names are contextual, not global categories. `Feature/components/` means -components private to Feature; `Feature/hooks/` means hooks private to Feature. -Do not lift them into a broad root-level dump merely because several files share -the same technical kind. - -Avoid ambiguous catch-all locations such as `misc/`, overly broad `helpers/`, -overly broad `utils/`, `common/`, `shared/`, or a global `types.ts` when their -contents do not form one coherent boundary. Prefer a precise contextual role, -direct co-location, or the actual capability name. - -Do not create a directory for a single leaf file unless the directory establishes -a real module boundary, owns private children, or follows a required repository -convention. Promote a growing leaf to a folder without changing its public import -surface when practical. - -## Separate Public Surface from Implementation - -Structure packages and substantial modules by visibility: - -- expose only intentional, stable entrypoints at the public boundary; -- use an `internal/` directory only when it represents a real package- or - application-level non-public boundary and the repository uses that model; -- organize `internal/` by capability rather than turning it into another dump; -- use each internal module's entrypoint as its local boundary; -- do not expose internal implementation again through package subpath exports; -- keep platform-specific entrypoints separate when consumers genuinely need - different runtime surfaces. - -Do not create `internal/` inside every owner. A file is already private when it -lives inside its owner's directory and is absent from that owner's public -entrypoint. Use the contextual role folders above, or keep a private leaf beside -the entrypoint. - -## Minimize Barrels - -Prefer direct imports. Do not add an `index.ts` to every directory merely because -the directory exists. - -Distinguish three different uses: - -- an implementation entrypoint such as `Component/index.tsx`, where the file - defines or composes the public component, is not a barrel; -- a small curated public surface at a real package or feature boundary may - re-export a few intentional capabilities; -- a convenience file that mechanically re-exports directory contents is a - barrel and should normally not exist. - -Allow a barrel only when the boundary is real, the exported set is small, -cohesive, stable, and immediately obvious, and consumers should not know the -internal paths. There is no useful numeric limit: aim for one curated surface per -real public module, not one barrel per folder. - -Inside private implementation, import the owning file directly. Never: - -- create chains where one barrel imports from or re-exports another barrel; -- use `export *` to sweep a directory into an API; -- re-export private files merely to shorten an import path; -- combine unrelated capabilities behind one convenience entrypoint; -- keep expanding a barrel after the origin and ownership of its exports stop - being obvious. - -When barrels begin multiplying, replace convenience re-exports with direct -imports before the dependency graph becomes opaque or cyclic. - -## Recursive Structure Examples - -Choose the shape from the current owner and its children. These examples express -the same rule in different contexts; they are not mandatory folder names. - -### Component ownership - -```text -Workspace/ -├── index.tsx -├── hooks/ -│ └── use-workspace-state.ts -└── components/ - ├── Canvas.tsx - └── Toolbar/ - ├── index.tsx - ├── hooks/ - │ └── use-toolbar-shortcuts.ts - └── components/ - └── ToolbarAction.tsx -``` - -`use-workspace-state.ts` is shared by Workspace children. The shortcut hook and -action component are private to Toolbar and therefore live inside Toolbar. - -### Composed hook - -```text -hooks/ -└── use-selection/ - ├── index.ts - └── hooks/ - ├── use-keyboard-selection.ts - └── use-pointer-selection.ts -``` - -The surrounding feature imports `use-selection/index.ts`. Its composing hooks -stay behind that hook module's boundary. - -### Recursive feature ownership - -```text -FileUpload/ -├── index.tsx -├── components/ -│ ├── Dropzone/ -│ │ ├── index.tsx -│ │ └── hooks/ -│ │ └── use-drag-state.ts -│ └── UploadProgress.tsx -├── hooks/ -│ ├── use-upload-progress.ts -│ └── use-upload-queue.ts -└── validators/ - ├── file-size.ts - └── file-type.ts -``` - -The root `hooks/` and `validators/` contain behavior shared inside FileUpload. -`use-drag-state.ts` belongs only to Dropzone, so it moves into that component's -own boundary. The same rule therefore repeats inside the feature without an -undifferentiated `internal/`. - -### Single private leaf - -```text -Summary/ -├── index.tsx -└── format-total.ts -``` - -Do not create `internal/`, `helpers/`, or `formatters/` for one private leaf -unless local convention requires it. Direct co-location already communicates -ownership. - -### Real package-level internal boundary - -```text -package/src/ -├── index.ts -├── native.ts -└── internal/ - ├── protocol/ - │ └── index.ts - └── runtime/ - └── index.ts -``` - -Here `internal/` is meaningful: package consumers may import only the declared -public entrypoints, while several non-public capabilities remain package-owned. - -Outsiders import the public module, not its private children. Do not expose an -internal file merely because another internal file needs it; place both under -their narrowest common owner. - -When any leaf gains private children, promote it to a folder with an entrypoint -and continue the same structure recursively. - -## Defer Route And Page Trees To Routing - -When a structural change creates, moves, or reorganizes route branches, page -boundaries, route declarations, or route-tree assembly, apply **routing** as the -authoritative structure. Use this skill recursively for the non-routing -implementation inside the page or layout boundary selected by **routing**. - -## Make Structural Changes Complete - -When moving, renaming, splitting, merging, or deleting files: - -1. Update all imports, aliases, entrypoints, package exports, tests, fixtures, - tooling configuration, and generated registries that reference the old path. -2. Move the implementation; do not leave duplicate old and new copies unless a - deliberate compatibility layer is required. -3. Preserve public import paths when the public contract is not meant to change. -4. Remove obsolete empty structure only when it is fully owned by the change. -5. Search for the old path and old exported names after the move. -6. Run the repository's focused structural verification, then its required - typecheck, lint, tests, and build as appropriate. diff --git a/.agents/skills/file-structure/agents/openai.yaml b/.agents/skills/file-structure/agents/openai.yaml deleted file mode 100644 index d1349ef..0000000 --- a/.agents/skills/file-structure/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "File Structure" - short_description: "Organize files by ownership and visibility" - default_prompt: "Use $file-structure to place files behind the narrowest correct module boundary." diff --git a/.agents/skills/forms/SKILL.md b/.agents/skills/forms/SKILL.md deleted file mode 100644 index f3761de..0000000 --- a/.agents/skills/forms/SKILL.md +++ /dev/null @@ -1,249 +0,0 @@ ---- -name: forms -description: Use whenever work creates, changes, reviews, or debugs a form, form field, input flow, validation schema, submit flow, create or edit form, server field error, react-hook-form register or Controller integration, or Zod form schema. Trigger even when the request only describes a product form such as sign-in, settings, search, filters, a dialog form, or an editable resource without naming the form libraries. ---- - -# Forms - -Build forms with `react-hook-form` + Zod and treat each form as a thin, -declarative boundary around rendered fields and submission. - -A field control may be: - -- an existing `Ui*` design primitive; -- a composition of several primitives; -- a product component with its own meaningful controlled or uncontrolled API. - -Inspect the repository's existing UI and product components before creating a -new control. Follow **components** for primitive reuse, product-component -ownership, and controlled/uncontrolled component contracts. - -## Schema and Types - -- Define the Zod schema close to the form unless the same form contract is - genuinely shared. Colocation beats premature extraction. -- The form schema mirrors the fields represented by the rendered form. It is - the readable inventory, validation contract, and value contract for that UI. -- Type form values with `z.infer<typeof formSchema>`. Never hand-maintain a - parallel `FormValues` interface. -- Wire validation through `zodResolver(formSchema)`. -- A form schema is not an API DTO and must not be shaped around one. Do not - create API types, proxy types, or DTO schemas inside the form. -- Give every stable field an explicit initial value through `defaultValues`. - Never use `undefined` as the value of a controlled field or `Controller`. -- Treat browser-managed file inputs separately. For conditionally mounted or - dynamically registered fields, deliberately choose registration, - unregistration, and default-value behavior instead of relying on omission. - -## Create And Edit Forms - -One form component may support both creation and editing by accepting an -optional existing entity: - -- entity present → edit mode; -- entity absent → create mode. - -Absence must mean create mode only. The owner must resolve loading, error, and -not-found states before mounting an edit form; do not temporarily render create -mode while an entity is still loading. - -Populate `defaultValues` explicitly from the entity: - -```ts -const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: entity?.name ?? "", - description: entity?.description ?? "", - }, -}); -``` - -Select only fields represented by the form. Do not spread the entire entity -into `defaultValues`. - -Treat these defaults as the initial snapshot for that form instance. When the -owner switches to a different entity, remount the form by identity: - -```tsx -<EntityForm key={entity?.id ?? "create"} entity={entity} /> -``` - -Key by stable identity, not by the entity object or all of its values. A -background query update for the same entity must not silently overwrite dirty -user input. - -Create both mutation hooks unconditionally, as required by React's hook rules, -and choose the operation inside `handleSubmit`: - -```ts -const onSubmit = handleSubmit(async (values) => { - if (entity) { - await updateMutation.mutateAsync({ - id: entity.id, - name: values.name, - description: values.description, - }); - return; - } - - await createMutation.mutateAsync({ - name: values.name, - description: values.description, - }); -}); -``` - -Do not use `useEffect` to copy entity data into form state. After a successful -update, call `reset()` with the committed form values only when the form remains -open and should establish a new pristine baseline. A form that closes or -navigates away does not need that reset. - -## Numeric and Coerced Inputs - -- HTML inputs usually produce strings. Decide explicitly what an empty value - means before converting it. -- Use `z.coerce.number()` only when its empty-string and coercion behavior is - correct for that field. Remember that `Number("")` is `0`. -- Prefer a deliberate `z.preprocess`, `register(..., { valueAsNumber: true })`, - or `setValueAs` when empty, optional, and invalid values need different - semantics. -- Apply the same discipline to dates, booleans, selects, and any other control - whose rendered value differs from its form value. - -## Validation - -- Per-field rules live on the field schema with a user-facing message - (`z.string().trim().min(1, "...")`). -- Cross-field rules use `.refine()` / `.superRefine()` and **must set `path`** so the - error attaches to the right field (e.g. confirm-password mismatch -> `path: ["confirmPassword"]`). - -## Submit and Pending State - -- Prefer one `handleSubmit(async (values) => { ... })` as the complete - form-submission flow. Keep request preparation, `mutateAsync`, field-error - handling, and success UX readable in that handler. -- Call the typed mutation with an explicit object built directly from form - values: - -```ts -const onSubmit = handleSubmit(async (values) => { - await mutation.mutateAsync({ - email: values.email, - password: values.password, - }); -}); -``` - -The mutation's typed parameter is the API contract and verifies the object. -Do not create form-to-DTO mappers, conversion helpers, proxy types, or local API -schemas. Form-only fields simply do not appear in the mutation call. - -- Use `mutateAsync` so the submit handler can await the operation and express - success and failure in normal control flow. -- `mutateAsync` rejects when the mutation function rejects, so an ordinary - `try`/`catch` handles submit failures without `throwOnError`. That option - controls render-phase propagation to an Error Boundary; do not enable it just - to make form error handling work. If the mutation function resolves an error - as a successful value or otherwise swallows it, correct or follow the - established contract in **api-integration** rather than compensating inside - the form. -- Compute one pending flag: - `isPending = formState.isSubmitting || mutation.isPending`. -- Drive duplicate-submission prevention and the submit control's pending - presentation from that flag. -- Keep canonical invalidation and API-wide mutation behavior inside the API - module's `mutationOptions`. See **api-integration**. -- Prefer local success UX directly after the awaited mutation: show feedback, - `reset()`, navigate, or close the form. -- Do not spread canonical mutation options and then overwrite their - `onSuccess` or `onError` callbacks in the form. - -## register vs Controller - -- **`register()` by default.** Native-input `Ui*` wrappers (`UiInput`, `UiTextarea`) - expose a native `value`/`onChange`/`ref` contract and bind directly: `{...register("email")}`. -- **`Controller` only when a component lacks a compatible native input - contract.** Custom primitives such as selects, switches, checkboxes, radio - groups, and segmented controls commonly need it. -- Product components may also act as fields. When their state must be owned by - the form, compose `Controller` with the component's controlled API - (`value`/`onValueChange` or its semantic equivalent). Do not move RHF into the - product component merely to make it usable by a form. -- Compose react-hook-form's `<Controller>` directly with the existing primitive - or product component at the field call site. -- Never create or reuse `ControlledUi{X}` components, `controlled.tsx` modules, or - reusable wrappers around `useController`. Keep RHF ownership visible in the form and - keep the `Ui*` layer independent of the form library. - -## Field Composition - -Prefer the established shadcn-style field composition when the project provides -it: a field container groups its label, control, optional description, and -validation error. The container owns field-level layout and invalid state; the -control keeps its own visual and interaction contract. - -This is a recommendation, not a mandatory component tree. Inspect existing -forms and the repository's UI primitives before choosing exact components, -names, props, or ordering. - -An adapted shadcn `Field` composition may look like: - -```tsx -<UiField data-invalid={!!errors.email}> - <UiFieldLabel htmlFor="email">Email</UiFieldLabel> - <UiInput - {...register("email")} - id="email" - type="email" - autoComplete="email" - aria-invalid={!!errors.email} - /> - {errors.email?.message && ( - <UiFieldError errors={[errors.email]} /> - )} -</UiField> -``` - -The names are illustrative. Use the project's equivalent rather than creating -these wrappers solely to match the example. Never restyle a base control to make -it fit a form; compose around it. - -## Server Field Errors - -- Catch submit failures in the `handleSubmit` callback when the form needs to - classify them. -- When the API returns a field-specific failure, attach it with - `setError(field, { message })`, then return. -- Detect the specific failure from the typed transport error (status/code/detail), not by - string-matching a generic message. Transport errors are thrown as received and preserve - their status/message/detail (owned by **api-integration**) — exploit that here instead of - flattening. -- General submit failures surface through the project's feedback flow, not - `setError`. -- Use one presentation path for each failure. Do not show a general mutation - error and then show the same failure again as a field error. - -## Cross-References - -- `Ui*` primitives, product fields, field-error presentation, and - controlled/uncontrolled component APIs → **components**. RHF `Controller` - composition remains owned here. -- `mutationOptions`, query/mutation key factories, and cache invalidation placement - -> **api-integration** (separate skill from state management). -- Submit success and failure feedback (`showError` / `showSuccess` or the - repository's equivalent) -> **user-feedback**. -- Submit failure propagation, reporting, retry, and Error Boundary policy -> - **error-handling**. -- Page/feature placement of the form module and where its files live -> **file-structure**. - -## Anti-Patterns - -- No `useEffect` to sync derived form state — derive in render or via watched values. -- No restyling base UI components to make a field fit; wrap them. -- No ternary expressions for conditional JSX. Use a simple logical condition - for one optional element and an ordered IIFE for multiple render branches. -- No form-to-DTO mapper layer, local API types, or duplicate API schemas. - -See `references/patterns.md` for terse, stack-specific patterns that should be -adapted to the repository's existing components and contracts. diff --git a/.agents/skills/forms/references/patterns.md b/.agents/skills/forms/references/patterns.md deleted file mode 100644 index cb39511..0000000 --- a/.agents/skills/forms/references/patterns.md +++ /dev/null @@ -1,172 +0,0 @@ -# Form Patterns - -Stack-specific, adaptable skeletons for react-hook-form, Zod, TanStack Query, -and a shadcn-style component system. Replace component and feedback names with -the repository's established equivalents. - -## (a) Basic Form + Mutation Submit with isPending - -```tsx -const formSchema = z.object({ - title: z.string().trim().min(1, "Title is required"), - quantity: z.number().positive("Must be greater than 0"), -}); -type FormValues = z.infer<typeof formSchema>; - -function ResourceForm() { - const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = - useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { title: "", quantity: 1 }, - }); - - const mutation = useMutation(createResourceMutationOptions()); - const isPending = isSubmitting || mutation.isPending; - - const onSubmit = handleSubmit(async (values) => { - // mutateAsync rejects when mutationFn rejects. throwOnError is only for - // render-phase Error Boundary propagation and is not required here. - try { - await mutation.mutateAsync({ - title: values.title, - quantity: values.quantity, - }); - - showSuccess("Saved"); - reset(); - } catch (error) { - showError(error, { fallbackMessage: "Unable to save" }); - } - }); - - const submitLabel = (() => { - if (isPending) return "Saving..."; - return "Save"; - })(); - - return ( - <form onSubmit={(e) => void onSubmit(e)}> - <UiInput - {...register("quantity", { valueAsNumber: true })} - type="number" - aria-invalid={!!errors.quantity} - /> - <UiButton type="submit" disabled={isPending}> - {submitLabel} - </UiButton> - </form> - ); -} -``` - -## (b) Cross-Field Refine (set path) - -```ts -const schema = z - .object({ - password: z.string().min(8), - confirmPassword: z.string().min(1, "Confirm your password"), - }) - .refine((v) => v.password === v.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], // attach error to the right field - }); -``` - -## (c) Build The Typed Mutation Parameter In handleSubmit - -```ts -const onSubmit = handleSubmit(async (values) => { - await signUpMutation.mutateAsync({ - email: values.email, - password: values.password, - }); -}); -``` - -`confirmPassword` remains part of `formSchema` because it is rendered in the -form, but it is simply not included in the typed mutation argument. - -## (d) Server Field Error via setError - -```ts -const onSubmit = handleSubmit(async (values) => { - try { - await mutation.mutateAsync({ - username: values.username, - password: values.password, - }); - } catch (error) { - if (isFieldTakenError(error)) { // detect from typed transport error - setError("username", { message: "Username is already taken" }); - return; // handled — do not rethrow - } - showError(error, { fallbackMessage: "Unable to save" }); - } -}); -``` - -## (e) Controller Fallback (no native input contract) - -```tsx -// Use for primitives or product fields that expose a controlled value contract. -<Controller - control={control} - name="enabled" - render={({ field }) => ( - <UiSwitch checked={field.value} onCheckedChange={field.onChange} /> - )} -/> -``` - -## (f) Shared Create / Edit Form - -Mount edit mode only after its entity is available. Remount when its identity -changes: - -```tsx -<EntityForm key={entity.id} entity={entity} /> -``` - -Use a distinct create-mode instance: - -```tsx -<EntityForm key="create" /> -``` - -Inside the shared form: - -```tsx -function EntityForm({ entity }: { entity?: Entity }) { - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: entity?.name ?? "", - description: entity?.description ?? "", - }, - }); - - const createMutation = useCreateEntityMutation(); - const updateMutation = useUpdateEntityMutation(); - - const onSubmit = form.handleSubmit(async (values) => { - if (entity) { - await updateMutation.mutateAsync({ - id: entity.id, - name: values.name, - description: values.description, - }); - return; - } - - await createMutation.mutateAsync({ - name: values.name, - description: values.description, - }); - }); - - // Render the same form fields for both modes. -} -``` - -Do not synchronize later entity updates into the form with `useEffect`. diff --git a/.agents/skills/implement-work-item/SKILL.md b/.agents/skills/implement-work-item/SKILL.md deleted file mode 100644 index c6b04f1..0000000 --- a/.agents/skills/implement-work-item/SKILL.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: implement-work-item -description: Claim and implement exactly one ready issue from a central project change bundle in its exact bound leaf checkout or worktree. Use when the maintainer explicitly asks to implement a named or next frontier issue, or when resuming its existing claim. Do not use for an unresolved Wayfinder issue, unapproved shaping, lightweight unrelated edits, or work in an inferred checkout. ---- - -# Implement Work Item - -Build one bounded unit from a fresh, explicit context. The issue tracks local -progress; `change.md` remains the parent contract. - -Read [the execution contract](references/execution-contract.md) before the -first claim in a repository. - -## Load before claiming - -1. Run `wfctl work issue show <change-id> <issue-id>` from the exact intended - leaf. If no issue was named, run `wfctl work issue list <change-id>` and - choose only a frontier issue. -2. Read every listed required file completely. This includes the parent spec, - selected issue, transitive blockers, and referenced artifacts. -3. After each complete read, run `wfctl work review file <change-id> <path>`. - Do not record a receipt after reading only headings, summaries, or excerpts. -4. Run `wfctl work status <change-id>` and verify the current Git root equals - the reported code root exactly. -5. Claim before analysis or edits: - -```sh -wfctl work issue claim <change-id> <issue-id> --actor "agent:<identity>" -``` - -The claim records repository, branch, revision, and worktree identity. Stop on -any mismatch instead of choosing a sibling checkout. - -## Implement one tracer bullet - -Invoke `analyze-with-graphify` in every repository this issue legitimately -touches, then inspect the actual source. Recheck relevant curated knowledge -with `align-project-knowledge`. Search may supplement the graph; it does not -replace it. - -Implement the smallest complete behavior that satisfies this issue. Prefer a -high public seam and work one behavior cycle at a time: - -1. add one externally meaningful failing check; -2. confirm it fails for the intended reason; -3. make the minimum production change that passes it; -4. run the focused check; -5. repeat, then run broader relevant checks. - -Tests must derive expected behavior from the approved contract or an -independent authority, never from the implementation they are meant to test. -Do not over-mock the behavior under review. - -After every material maintainer turn or meaningful investigation cycle, apply -the preservation test from `manage-project-work`: if losing newly learned -information could cause repeated material investigation, a different choice, -misunderstanding, or unsafe action in a fresh session, append a complete entry -to the issue's `Discovery ledger`. Record observation, evidence, implication, -scope, and disposition without forcing it into a predefined finding category. -Update evidence and current understanding next, then refresh the issue's single -structured checkpoint last: - -```sh -wfctl work checkpoint <change-id> --issue <issue-id> \ - --actor "agent:<identity>" \ - --state "<current state>" \ - --last "<last completed action>" \ - --next "<exact next action>" -``` - -Use `--status blocked --blocker "<reason>"` when progress genuinely cannot -continue. Record deviations in the parent `change.md` when they affect approved -scope, acceptance, or decisions; refresh the parent checkpoint and reopen -framing review before continuing materially different work. - -The checkpoint may identify the latest discovery and its effect on the next -action, but the full information stays in the semantic record or a linked -artifact. Never hide a discovery only in checkpoint prose, command output, or -conversation memory. - -## Resolve honestly - -Inspect the real diff and production path. Record commands, direct source -evidence, limitations, placeholders, and unresolved risk. With normal -maintainer authorization, preserve code in the exact bound Git commit; `wfctl` -never commits automatically. - -Resolve only this issue: - -```sh -wfctl work issue complete <change-id> <issue-id> \ - --summary "<delivered outcome>" \ - --evidence "<direct inspection or command result>" -``` - -If interrupted, refresh the claimed issue checkpoint before stopping. A fresh -session begins with `wfctl work context --stage resume`, reads every required -file and discovery entry completely, and resumes the existing exact claim; it -does not infer another issue, actor, checkout, or code root. If -deliberately giving the issue back, run `wfctl work issue release`; it resets -the issue checkpoint to ready. Completion makes the issue checkpoint terminal; -then refresh the parent checkpoint with the next frontier action. Do not mark a -partial outcome completed. Final change-wide review, drafting the pages this work -changes, and closure belong to `verify-project-work` after every required issue -is terminal; the maintainer's approval of those pages comes after the bundle is -closed, and holds nothing up. diff --git a/.agents/skills/implement-work-item/agents/openai.yaml b/.agents/skills/implement-work-item/agents/openai.yaml deleted file mode 100644 index cd9c2c7..0000000 --- a/.agents/skills/implement-work-item/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Implement Work Item" - short_description: "Implement one issue with durable discoveries" - default_prompt: "Use $implement-work-item to claim or resume one frontier issue in the exact bound checkout, preserve consequential discoveries in the issue, verify it, and refresh its checkpoint last." -policy: - allow_implicit_invocation: false diff --git a/.agents/skills/implement-work-item/references/execution-contract.md b/.agents/skills/implement-work-item/references/execution-contract.md deleted file mode 100644 index 5eea8b5..0000000 --- a/.agents/skills/implement-work-item/references/execution-contract.md +++ /dev/null @@ -1,29 +0,0 @@ -# Work-item execution contract - -## Workspace invariants - -- The central bundle is the record workspace. -- Each `Code root` from `wfctl work status` is an implementation workspace. -- A linked worktree is a distinct root even when it shares Git objects. -- Never infer a checkout from branch name, repository name, sibling paths, or - the location of `change.md`. -- Re-run status after directory changes, compaction, branch changes, and before - verification. - -## Progress invariants - -- Work exactly one claimed issue. -- Keep the issue current after requirements, evidence, scope, or the next - action changes. -- Keep parent acceptance and decisions in `change.md`; link rather than copy. -- A fresh session resumes from `wfctl work context`, the full files it lists, - and the exact claim—not from chat memory. - -## Verification invariants - -- Inspect the production path, not only tests or generated graph output. -- Prefer behavior checks at stable public seams. -- Run focused checks during development and the broader relevant suite before - resolution. -- Separate verified facts, inference, and unverified limitations. -- Passing checks do not excuse an acceptance criterion that was never traced. diff --git a/.agents/skills/localization/SKILL.md b/.agents/skills/localization/SKILL.md deleted file mode 100644 index 1ed1b71..0000000 --- a/.agents/skills/localization/SKILL.md +++ /dev/null @@ -1,500 +0,0 @@ ---- -name: localization -description: Use whenever work adds, changes, reviews, or debugs user-facing copy in an application that uses localization, including visible text, labels, placeholders, accessibility text, validation messages, notifications, translation calls, source-text or semantic keys, locale resources, interpolation, plurals, context variants, typed translation APIs, enum labels, locale switching, or translation linting. Trigger even when the request mentions only changing UI wording without explicitly naming i18n. ---- - -# Localization - -When an application uses localization, every user-facing message goes through -its translation layer: - -- visible text and actions; -- labels, descriptions, placeholders, and hints; -- empty, loading, success, and error messages; -- validation feedback and notifications; -- `aria-label`, image `alt`, and other accessibility text. - -An application without localization should not receive a speculative partial -i18n layer. Adopt this skill when localization already exists, is being -introduced, or a translatable-string audit is explicitly requested. - -Inspect the installed localization library, initialization, source locale, -resource files, translator APIs, plural/context conventions, and validation -commands before changing copy. - -## Use Source Text As The Default Key - -For ordinary UI copy, use the canonical source-language text itself as the -translation key: - -```ts -translate("Cancel"); -translate("Create a new project"); -translate("Changes are saved automatically"); -``` - -The source catalog repeats the source text as its value: - -```json -{ - "Cancel": "Cancel", - "Changes are saved automatically": "Changes are saved automatically", - "Create a new project": "Create a new project" -} -``` - -This makes the application searchable from its interface: copying visible text -and searching the repository should lead directly to the resource and ordinary -call sites. - -Do not replace ordinary copy with invented page-tree, component, kebab-case, or -UI-role identifiers such as: - -```text -projects.details.header.delete-project-btn -settings.notifications.description-body -``` - -File location and presentation role are not stable message identities. Moving a -component or changing a label into a button must not rename its translation. - -Use the exact source message, including meaningful capitalization and -punctuation. A wording change creates a new source key and requires existing -translations to be reviewed. Remove the obsolete key after migrating every call -site. - -## Reuse Meaning, Disambiguate Context - -Repeated source text is not inherently a collision. - -- Same text and same meaning → reuse one key. -- Same text but different meaning or required translation → disambiguate it. - -Prefer the localization engine's context feature: - -```ts -translate("Open", { context: "action" }); -translate("Open", { context: "state" }); -``` - -Store the base message and the engine's contextual variants in every locale. -For example, i18next uses its configured `contextSeparator` (`_` by default): - -```jsonc -// source locale -{ - "Open": "Open", - "Open_action": "Open", - "Open_state": "Open" -} -``` - -```jsonc -// another locale -{ - "Open": "Відкрити", - "Open_action": "Відкрити", - "Open_state": "Відкрито" -} -``` - -The call site passes the unsuffixed source key and semantic context. The -localization engine resolves the appropriate contextual resource key: - -```text -translate("Open", { context: "action" }) -> Open_action -translate("Open", { context: "state" }) -> Open_state -``` - -Do not append the context suffix manually at call sites. Follow the installed -engine's exact separator, fallback, typing, and plural/context composition -rules. Keep a base entry when the engine uses it as the non-contextual fallback. - -Context describes linguistic meaning, not file placement. Add a translator -comment when the library or catalog supports one. - -If the established runtime has no context mechanism, use the project's explicit -disambiguation convention while keeping the source text searchable, for -example: - -```ts -translate("Open|action"); -translate("Open|state"); -``` - -```jsonc -// source locale -{ - "Open|action": "Open", - "Open|state": "Open" -} -``` - -```jsonc -// another locale -{ - "Open|action": "Відкрити", - "Open|state": "Відкрито" -} -``` - -Do not invent a delimiter or alternate context system when the project already -has one. - -Short words such as `"Save"`, `"Cancel"`, `"Continue"`, and `"Name"` follow the -same rule. Use the source text directly when its meaning is shared; add context -only for a real ambiguity. - -## Keep Whole Messages Together - -Message length alone is not a reason to invent a semantic key. A sentence or -paragraph that forms one translatable unit may remain its own source-text key: - -```ts -translate( - "Deleting this project will permanently remove its settings and associated data.", -); -``` - -Never split natural language into separately translated fragments merely to -shorten a key. Translators must be able to reorder the whole message. - -Use a stable semantic id with an explicit source/default value only when the -content is genuinely managed as structured content rather than ordinary UI -copy, for example: - -- multi-paragraph help or onboarding content; -- localized Markdown or rich text; -- legal documents; -- large independently maintained content blocks. - -```ts -translate("project-deletion-explanation", { - defaultValue: projectDeletionExplanation, -}); -``` - -## Interpolation, Plurals, And Formatting - -Keep placeholders inside the complete source message and pass their values -through the translator: - -```ts -translate("Delete {{name}}?", { name }); -translate("Created by {{author}}", { author }); -``` - -Do not concatenate or template together fragments of natural language. A -rendered message containing a dynamic value may not exactly match its source -key, but its static wording remains searchable. - -### Plurals - -Use the localization engine's plural/select support for counts and grammatical -variants. Do not choose English singular/plural forms with component logic. -Follow the installed engine's resource format because plural categories differ -between locales. - -For example, i18next JSON v4 resolves plural variants from a base key and the -required `count` option: - -```ts -translate("{{count}} project", { count }); -``` - -```jsonc -// source locale -{ - "{{count}} project": "{{count}} projects", - "{{count}} project_zero": "No projects", - "{{count}} project_one": "{{count}} project", - "{{count}} project_other": "{{count}} projects" -} -``` - -```jsonc -// a locale with additional plural categories -{ - "{{count}} project": "{{count}} проєктів", - "{{count}} project_zero": "Немає проєктів", - "{{count}} project_one": "{{count}} проєкт", - "{{count}} project_few": "{{count}} проєкти", - "{{count}} project_many": "{{count}} проєктів", - "{{count}} project_other": "{{count}} проєкту" -} -``` - -The base entry keeps the ordinary typed lookup and non-contextual fallback -explicit. The engine selects `_zero`, `_one`, `_few`, `_many`, or `_other` -according to the active locale. A locale only defines the categories required -by the installed engine and its plural rules; do not copy English categories -blindly. - -For i18next, the option must be named `count`. Other engines may use another -resource shape or ICU message syntax. Reuse the project's installed plural -mechanism and verify its current official documentation. - -Context and plural variants may be combined. Pass both `count` and `context`; -the engine composes their resource suffixes. Never construct `_one`, `_other`, -or combined suffixes in application code. - -### Locale-Aware Formatting - -Format dates, times, numbers, percentages, units, and currencies with the -project's locale-aware formatter. Do not interpolate locale-insensitive -`toString()` output into a translated sentence. - -When the localization engine supports `Intl`-backed formatting, keep the -formatter inside the complete message. For i18next versions that support its -built-in formatters: - -```jsonc -{ - "Total: {{amount, currency(USD)}}": "Total: {{amount, currency(USD)}}", - "Updated on {{date, datetime}}": "Updated on {{date, datetime}}", - "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete": "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete" -} -``` - -```ts -translate("Total: {{amount, currency(USD)}}", { - amount: 1250, -}); - -translate("Updated on {{date, datetime}}", { - date: updatedAt, - formatParams: { - date: { - dateStyle: "medium", - timeStyle: "short", - }, - }, -}); - -translate( - "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete", - { - progress: 0.725, - }, -); -``` - -The active locale controls separators, currency presentation, ordering, and -date/time wording. Other locales keep the same placeholders but may move them -within the message. - -If the localization engine does not own formatting, format through the -project's locale-aware formatter and interpolate the result: - -```ts -translate("Total: {{amount}}", { - amount: formatCurrency(amount, { currency: "USD", locale: activeLocale }), -}); -``` - -## Use Stable Domain Keys For Enums And Machine Values - -Enums and other closed machine-defined sets already have stable identities. -They do not need source text as their lookup key. - -When a value is presented in multiple ownership areas, keep one exhaustive, -typed translator: - -```ts -const STATUS_KEYS = { - [Status.Active]: "enums.status.active", - [Status.Archived]: "enums.status.archived", -} satisfies Record<Status, TranslationKey>; - -export const translateStatus = (status: Status) => - translate(STATUS_KEYS[status]); -``` - -The resource values remain searchable: - -```json -{ - "enums.status.active": "Active", - "enums.status.archived": "Archived" -} -``` - -Apply this to stable statuses, roles, modes, categories, and similar constants. -The key must be mechanically derived from the domain value, not creatively -named after one component. - -Use a local source-text key when a one-off label only happens to resemble an -enum value. Do not route unrelated copy through a shared enum translator. - -## Derive Key Types From The Source Catalog - -The canonical source locale is the key authority. Derive key and language types -from real resources instead of maintaining manual unions: - -```ts -import source from "./locales/en.json"; - -export const resources = { - en: { translation: source }, - uk: { translation: uk }, -} as const; - -export type Language = keyof typeof resources; -export type TranslationKey = keyof typeof source; -``` - -For a flat source-text catalog, prefer the simple `keyof` type. Do not build a -recursive path utility that: - -- permits intermediate objects as translation results; -- generates both dot and bracket forms; -- duplicates the localization library's own key inference; -- slows TypeScript as the catalog grows. - -When the library supports resource-based type augmentation, connect it directly -to the source catalog. For i18next: - -```ts -declare module "i18next" { - interface CustomTypeOptions { - defaultNS: "translation"; - keySeparator: false; - nsSeparator: false; - returnObjects: false; - resources: { - translation: typeof source; - }; - } -} -``` - -Keep runtime configuration and type augmentation aligned. A flat natural-key -i18next catalog normally requires: - -```ts -i18n.init({ - resources, - keySeparator: false, - nsSeparator: false, -}); -``` - -Otherwise periods or colons inside sentences may be interpreted as key or -namespace separators. Follow the exact installed library version and official -documentation when configuring this foundation. - -If the project uses multiple catalogs or namespaces, split them for real -ownership, loading, or deployment reasons. Do not recreate page-tree -namespacing merely to organize keys visually. - -JSON resources provide useful key inference but may not preserve enough literal -information for fully typed interpolation variables. If stronger typing is -needed, prefer source resources defined with `as const` in TypeScript or -generated declarations. Never hand-maintain a second resource interface. - -## Keep Translator APIs Honest - -Prefer the localization library's typed translator directly. A project wrapper -may adapt ergonomics, but it must preserve: - -- the inferred key type; -- option and interpolation types; -- context and plural overloads; -- the real return type. - -Do not weaken the boundary with `any`, `as unknown as string`, or a general -`string` key. - -Do not custom-memoize translated results by only `key + options`. The active -locale, loaded resources, and runtime language changes also affect the result. -Use the localization engine's own resource behavior unless the project has a -proven locale-aware caching abstraction. - -Inside reactive UI, use the project's translation hook so the component -responds to locale changes. Outside React, use the established direct -translator. - -Do not eagerly translate module-level constants when the application can change -locale without reloading. Store keys and translate at the consumption boundary, -or construct locale-sensitive schemas/options through the project's established -flow. Module-level translation is acceptable only when the application -deliberately reloads on locale change or otherwise guarantees reevaluation. - -Do not pass a translator through component props when each component can obtain -the project translator from its normal context. - -## Validate Every Locale - -Typing call sites from the source locale proves that a used source key exists. -It does not automatically prove that every other locale has the same keys. - -Treat localization validation as layered. The foundation must detect missing, -extra, and orphaned keys; incompatible placeholders; incomplete plural/context -variants; invalid resource shapes; and stale keys after copy changes. - -No single successful command proves all of these properties. Combine the -repository's resource validator, exact parity or policy checks, source usage -analysis when it understands the project's translator API, and type-checking. - -When using or considering `@lingual/i18n-check`, read -[references/i18n-check.md](references/i18n-check.md) before trusting it. Its -resource checks are useful, but source parsing and i18next plurals have limits. - -Prefer automatic correction for deterministic ordering, but do not silently -fabricate translations. A source-language value copied into another locale must -remain visibly untranslated according to the project's workflow. - -Keep resource keys deterministically sorted when the project stores catalogs in -version control. - -## What Not To Translate - -- User-generated or backend-provided content. -- User names, record ids, filenames, and machine-readable codes. -- Proper-noun brands that intentionally remain identical across locales. -- Raw dynamic values that should be formatted rather than translated. - -Translate the surrounding static message as one unit. Do not assume -backend-provided labels are localized unless the API contract guarantees it. - -## Workflow - -1. Inspect the localization runtime, source locale, resources, typed translator, - context/plural conventions, and validation commands. -2. Find every affected user-facing string, including accessibility, - notification, and validation copy. -3. Search the exact source text before adding it. -4. Reuse an existing key when both source text and meaning match. -5. Add linguistic context when identical source text needs a different - translation. -6. Use a typed domain key only for an enum/machine value or a justified - structured-content exception. -7. Add the source entry and update other locales through the project's - translation workflow. -8. Replace the call site with the typed translator and keep interpolation or - plural logic inside the message. -9. Remove replaced or stale keys. -10. Run locale sorting/parity validation, formatting, linting, and type-checking. - -## Avoid - -- invented page/component/element keys for ordinary copy; -- namespaces derived from file-system position; -- duplicating identical messages per call site; -- sharing identical source text that needs different linguistic context; -- semantic ids for ordinary sentences merely because they are long; -- sentence fragments and translated-string concatenation; -- runtime-generated or untyped translation keys; -- manual key unions or duplicate resource interfaces; -- recursive path types for a flat source-text catalog; -- casts that hide object or missing-key results; -- custom translation memoization that ignores locale; -- assuming source-locale typing validates every locale. - -## Related Skills - -- Localized validation messages and form lifecycle → **forms**. -- User-facing component copy and locale-reactive rendering → **components**. -- Localized success, error, warning, notification, and recovery feedback → - **user-feedback**. -- Placement of localization modules and resource files → **file-structure**. diff --git a/.agents/skills/localization/references/i18n-check.md b/.agents/skills/localization/references/i18n-check.md deleted file mode 100644 index 8a2e157..0000000 --- a/.agents/skills/localization/references/i18n-check.md +++ /dev/null @@ -1,569 +0,0 @@ -# i18n-check Integration Reference - -Use this reference when a project already uses `@lingual/i18n-check`, when -introducing it, or when deciding whether its output is sufficient for a -localization change. - -## Contents - -- [Role And Boundaries](#role-and-boundaries) -- [Supported Checks](#supported-checks) -- [Recommended Commands](#recommended-commands) -- [Required Validation Layers](#required-validation-layers) -- [Day-To-Day Workflows](#day-to-day-workflows) -- [Source Usage Compatibility](#source-usage-compatibility) -- [Source-Text Key Edge Cases](#source-text-key-edge-cases) -- [Interpolation And Rich Messages](#interpolation-and-rich-messages) -- [Plurals And Context](#plurals-and-context) -- [Catalog And File Edge Cases](#catalog-and-file-edge-cases) -- [Ignore, Exclude, And Reporting](#ignore-exclude-and-reporting) -- [Version-Specific Caveats](#version-specific-caveats) -- [Integration Checklist](#integration-checklist) - -## Role And Boundaries - -Treat `i18n-check` as a resource compatibility checker, not as the complete -localization authority. - -It is effective at comparing a canonical source locale with target locale files -and returning a non-zero exit code for detected problems. This makes it useful -for local validation, pre-commit hooks, and CI. - -It does not translate copy, migrate renamed keys, judge translation quality, or -prove that every runtime localization path works. A successful run only proves -the checks that were actually enabled and supported by the installed version. - -Inspect all of the following before configuring it: - -1. The installed `@lingual/i18n-check` version. -2. The localization engine and message format. -3. The source locale and resource layout. -4. The project's translator functions, hooks, and rich-text components. -5. The runtime key, namespace, plural, and context separators. -6. Existing type-check, lint, extraction, and CI commands. - -Consult the installed package and current official documentation instead of -assuming that behavior described for another version still applies: - -- <https://lingual.dev/i18n-check/> -- <https://github.com/lingualdev/i18n-check> - -## Supported Checks - -The CLI exposes four checks: - -| Check | Intended guarantee | -| --- | --- | -| `missingKeys` | A source key exists in each matched target resource. | -| `invalidKeys` | Source and target messages preserve compatible message elements. | -| `unused` | A source-locale key was not found in parsed application source. | -| `undefined` | A parsed application key does not exist in the source locale. | - -By default, use `missingKeys` and `invalidKeys` as the dependable resource -validation layer. `unused` and `undefined` require `--unused`/`-u` and are only -reliable when the parser recognizes the project's actual call-site syntax. - -For i18next messages, `invalidKeys` can detect common structural changes such -as: - -- a missing, added, or renamed interpolation variable; -- escaped versus unescaped interpolation; -- changed i18next nesting expressions; -- missing or changed rich-text tags; -- changed interval-plural expressions supported by the parser. - -It deliberately ignores ordinary source and target text differences. It cannot -determine whether a translation is accurate, grammatical, current, or -appropriate for its context. - -The CLI can load one or multiple locale folders and supports common layouts -such as one file per locale, one folder per locale, and matching multiple files -inside each locale folder. Discovery and matching do not prove that an expected -locale or file exists; validate that separately. - -Format behavior is version-specific. Inspect support for ICU, i18next, -react-intl, and next-intl in the installed package instead of assuming that -every check works equally for every format. - -## Recommended Commands - -Make the resource contract explicit: - -```json -{ - "scripts": { - "validate:i18n:resources": "i18n-check -l src/localization/locales -s en -f i18next -o missingKeys invalidKeys" - } -} -``` - -Adjust paths, source locale, and format to the inspected project. Supported -resource inputs are JSON and YAML. - -Run the resource check: - -- after adding, removing, or changing localized copy; -- in the normal local validation workflow; -- in a pre-commit hook when it remains fast; -- in CI as the authoritative blocking gate. - -Pre-commit alone is insufficient because it can be bypassed. - -Only add source usage validation after a compatibility test: - -```json -{ - "scripts": { - "validate:i18n:usage": "i18n-check -l src/localization/locales -s en -f i18next -u src -o unused undefined" - } -} -``` - -Do not merge this command into a blocking workflow merely because it runs. First -confirm that it finds representative calls through every project translator, -hook, component wrapper, namespace, context, and dynamic-key registry. - -Prefer the standard reporter for actionable local and CI output. Use the -summary reporter only when exact affected keys are available through another -artifact. - -When the CLI cannot represent the project's resource ownership, use the -package's documented public check functions to compose project tooling. Verify -the installed exports and keep project-specific policy checks outside the -package. Do not depend on private `dist/` modules. - -## Required Validation Layers - -Use `i18n-check` as one part of this stack: - -| Layer | Responsibility | -| --- | --- | -| Resource validation | Missing source keys and structurally incompatible messages. | -| Project policy/parity validation | Expected locales/files, reverse base-key parity, plural/context completeness, and source-text policy. | -| Type-checking | Valid call-site keys, options, interpolation values, context, and count types. | -| Source usage analysis | Undefined and orphaned keys when the parser understands the project API. | -| Runtime or integration tests | Locale switching, fallback behavior, lazy resources, rich messages, and formatting. | -| Human review | Meaning, grammar, tone, cultural fit, and product terminology. | - -The project-specific policy validator should cover gaps that `i18n-check` does -not reliably cover: - -- an explicit manifest of supported locales and required resource files; -- bidirectional parity for ordinary keys and context identities after grouping - locale-specific plural variants; -- target-only and orphaned keys; -- duplicate resource keys; -- source-text key conventions and justified semantic-key exceptions; -- required plural categories for each locale; -- combined context and plural variants; -- placeholder compatibility in target-only plural categories; -- deterministic resource ordering when required. - -Do not weaken these guarantees to fit one tool. Extend the validation stack. - -## Day-To-Day Workflows - -### Add Copy - -1. Search for an existing source message with the same meaning. -2. Add the source entry. -3. Add or explicitly queue every target translation through the established - workflow. -4. Update the typed call site. -5. Run resource validation, project parity validation, and type-checking. - -`missingKeys` should identify a target locale that did not receive the new -source key. It cannot create the translation. - -### Change Source-Text Copy - -A wording change is a key migration: - -```ts -translate("Delete project?"); -translate("Delete this project?"); -``` - -Perform it atomically: - -1. Find every call site of the old exact message. -2. Add the new source key. -3. Carry each existing translation to the new key and review it against the new - meaning. -4. Update all call sites. -5. Remove the obsolete key from every locale. -6. Run resource validation, reverse parity, source usage validation when - compatible, and type-checking. - -`missingKeys` will report the new key when targets still contain only the old -key. It will not identify the change as a rename, migrate translations, or -report the old target-only key. - -Capitalization, punctuation, and meaningful whitespace are part of a -source-text key. Even a small wording edit creates a distinct key. - -### Change Interpolation - -When changing: - -```text -Hello {{name}} -Hello {{user}} -``` - -update the source message, every target message, the call-site option, and its -type. `invalidKeys` can catch a target that retained `{{name}}`; it does not -prove that the call site supplies `user`. - -### Remove A Feature - -Remove its call sites and owned source keys, then remove the same keys from -every target locale. - -The basic resource check will not report target-only leftovers. Use reverse -parity and, when compatible, `unused`. - -### Add A Locale Or Resource File - -Register the locale in the project's explicit locale manifest, create every -required resource file, then run validation. - -Do not depend on directory discovery to prove completeness. A checker cannot -report a locale or file that it was never told must exist. - -## Source Usage Compatibility - -The i18next source parser commonly recognizes direct forms such as: - -```ts -t("Save"); -i18n.t("Save"); -const { t } = useTranslation(); -t("Save"); -``` - -It also recognizes `Trans` and can be given additional component wrapper names -through `--parser-component-functions`. - -Do not assume that it recognizes project wrappers: - -```ts -translate("Save"); - -const tx = useTranslate(); -tx("Save"); -``` - -The CLI option for additional component functions extends `Trans` component -recognition; it does not necessarily configure arbitrary translation function -or hook names. Verify the installed version before relying on wrapper support. - -Do not rename a clear project API merely to satisfy a static parser. Prefer a -thin project-aware source checker or contribute configurable function support -upstream. - -Avoid importing private `dist/` parser modules into permanent project tooling. -Internal package paths and options are not a stable public contract. - -### Dynamic Keys - -Static analysis cannot generally resolve: - -```ts -t(`status.${status}`); -t(prefix + id); -translate(KEY_FROM_RUNTIME_DATA); -``` - -Prefer explicit typed registries for closed machine-defined values: - -```ts -const STATUS_KEYS = { - active: "enums.status.active", - archived: "enums.status.archived", -} as const; -``` - -Validate the registry exhaustively with TypeScript and include it in the -project's localization policy check. Do not scatter ignores for dynamic keys. - -### False Usage - -Inspect whether the selected source paths include: - -- tests and fixtures; -- stories and examples; -- generated code; -- comments containing `t(...)`; -- dead or unreachable modules. - -These can keep a production key classified as used. Some parser versions scan -translation-looking calls in comments. - -An unrelated application function named `t` can create the opposite problem: -the parser may treat its string argument as a translation key. - -Object-returning calls may also cause a whole subtree to be skipped: - -```ts -t("countries", { returnObjects: true }); -``` - -Treat an unused report as static evidence that still requires ownership-aware -review. - -### Namespaces - -Test multiple namespaces explicitly. Some versions treat a key as used without -fully proving that it was used through the correct namespace. - -Source-text keys containing `:` need particular care. A source parser may split: - -```ts -t("Error: invalid value"); -``` - -as namespace `Error` plus key ` invalid value`, even when runtime i18next uses -`nsSeparator: false` or natural-key detection. - -Resource-only checks remain useful, but `unused` and `undefined` are unreliable -until this case passes a project smoke test. - -## Source-Text Key Edge Cases - -`i18n-check` compares resource keys; it does not enforce the project's key -policy. - -Add project validation for these invariants: - -- ordinary source-text keys follow the canonical source wording; -- the canonical source value has not silently drifted from its key; -- context variants intentionally differ from their base key; -- semantic keys are limited to enums, machine values, or documented structured - content; -- a flat literal key cannot collide with an equivalent nested resource path. - -Do not apply a universal `key === value` rule blindly. Context variants and -justified semantic keys legitimately differ: - -```json -{ - "Open": "Open", - "Open_action": "Open", - "enums.status.active": "Active" -} -``` - -Literal periods and colons must agree with runtime `keySeparator` and -`nsSeparator` behavior. The resource checker does not prove runtime lookup. - -Treat plural and context suffixes as reserved according to the installed -localization engine. A normal semantic key that accidentally ends in `_one` or -`_other` may be normalized as a plural form by tooling. - -## Interpolation And Rich Messages - -Use `invalidKeys` to protect the structural contract between source and target -messages, then keep call-site correctness in the type system. - -Validate representative cases: - -- reordered placeholders remain valid; -- renamed or missing placeholders fail; -- escaped and unescaped interpolation cannot be mixed accidentally; -- formatting directives preserve their arguments; -- nested translation expressions reference real keys; -- rich-text tags preserve the elements required by the renderer. - -The checker may compare a set of message elements without proving their exact -semantic nesting, rendering behavior, or component binding. - -It also does not validate: - -- that a custom formatter is registered; -- that date, time, number, currency, or unit inputs have the right runtime type; -- that the active locale reaches the formatter; -- that a nested `$t(...)` reference resolves; -- that translated prose retained the intended meaning. - -Test those boundaries through types and focused runtime tests. - -## Plurals And Context - -i18next plural categories vary by locale and are based on `Intl.PluralRules`. -The `count` option is required for plural selection. Context and plural suffixes -can be combined. - -Consult the installed engine's current documentation: - -- <https://www.i18next.com/translation-function/plurals> -- <https://www.i18next.com/translation-function/context> - -Do not treat a successful i18next `missingKeys` check as proof of plural -completeness. Some `i18n-check` versions normalize plural suffixes to a base key. -Consequently, one existing target variant may satisfy the check even when other -required categories are absent. - -For example, this target may pass base-key presence despite being incomplete: - -```json -{ - "project_one": "{{count}} project" -} -``` - -Locale-specific categories can also escape structural comparison when the -source locale does not define the same category. A broken target `_few` or -`_many` message may therefore remain undetected. - -The project policy validator must: - -1. Determine cardinal categories for each supported locale. -2. Validate ordinal categories separately when used. -3. Treat `_zero` as an explicit optional override unless product requirements - make it mandatory. -4. Require the correct variants for every pluralized base key. -5. Repeat the check for every context variant. -6. Validate interpolation elements across all target-only categories. -7. Respect the installed engine's configured plural and context separators. - -Do not copy English plural categories into every locale merely to satisfy a -checker. - -## Catalog And File Edge Cases - -### Reverse Parity - -The normal comparison direction is source to target. A target-only key can pass -`missingKeys` and `invalidKeys`. - -Run a locale-aware reverse comparison or bidirectional parity check to find: - -- old target keys left after a source-text rename; -- keys removed from the source but not from targets; -- accidental target-only additions. - -Normalize recognized plural-category suffixes before deciding that a target key -is extra. Preserve context identity: a target-only context remains suspicious, -while a target locale may legitimately require `_few` or `_many` when the -source locale does not. - -### Missing Files And Locales - -Directory discovery only compares files it finds and can match. It does not -prove that a deleted locale or resource file was expected. - -Maintain an explicit locale/resource manifest and verify it before content -comparison. - -### Empty And Null Values - -Verify installed-version behavior for empty strings, `null`, `false`, and -numeric values. Some implementations use truthiness for missing checks, making -an intentionally empty string appear missing. - -Prefer string-valued leaf messages unless the established engine explicitly -supports another resource shape. - -### Duplicate Keys - -Standard `JSON.parse` accepts duplicate object keys and silently keeps the last -value. A checker that reads resources through `JSON.parse` cannot report the -overwritten definition. - -Use a duplicate-aware JSON parser, formatter, or lint rule before running -resource comparison. YAML parser behavior must also be verified rather than -assumed. - -### Nested And Flat Resources - -Many checkers flatten nested objects into dotted paths. Avoid mixing: - -```json -{ - "a.b": "flat", - "a": { - "b": "nested" - } -} -``` - -This can produce a collision after flattening even when the raw JSON keys are -different. - -### Unsupported Sources - -The CLI resource loader supports JSON and YAML. TypeScript resource objects, -remote catalogs, generated bundles, or custom formats require a generation -step or public API integration. - -Source parsing commonly covers `js`, `jsx`, `ts`, and `tsx`. Verify other -extensions such as `mjs`, `mts`, MDX, Vue, or Svelte before enabling blocking -usage checks. - -## Ignore, Exclude, And Reporting - -Use `--ignore` and `--exclude` only for an explicit, reviewed exception. - -Each exception must identify: - -- the exact key, locale, file, or ownership boundary; -- why the normal invariant does not apply; -- whether the exception is temporary; -- how removal will be detected. - -Avoid broad wildcard ignores. Verify their matching semantics in the installed -version; some versions use substring matching for wildcard prefixes and can -silence unrelated keys. - -Do not exclude an entire locale merely because it is incomplete unless product -policy explicitly allows that locale to ship incomplete. - -Use the standard reporter when developers must fix individual keys. A summary -is useful for metrics, but counts alone are insufficient remediation output. - -## Version-Specific Caveats - -The following behaviors were observed in `@lingual/i18n-check@0.9.5`. Re-test -them when the installed version differs: - -- the i18next source parser defaults to `t`, `useTranslation`, - `withTranslation`, and `Trans`; -- custom `translate` and custom translation hooks are not configurable through - the documented CLI; -- parsed keys containing `:` are split as namespace-prefixed keys; -- context usage normalization assumes `_`; -- plural suffixes are collapsed during missing and usage checks; -- namespace-aware usage matching is incomplete; -- `returnObjects: true` creates skippable key subtrees; -- source-file `--exclude` behavior does not necessarily match locale-file - exclusion behavior; -- wildcard ignores use broad substring matching; -- missing checks treat falsy target values as absent; -- interpolation prefix/suffix comparison contains an implementation defect. - -Do not preserve these limitations as timeless rules. They describe why the -installed tool must be tested against the real project instead of trusted by -name. - -## Integration Checklist - -Before declaring localization validation complete: - -- [ ] Identify the canonical source locale. -- [ ] Verify every expected locale and resource file through a manifest. -- [ ] Run `missingKeys` and `invalidKeys`. -- [ ] Run locale-aware reverse or bidirectional base-key parity. -- [ ] Detect duplicate resource keys before parsing destroys that evidence. -- [ ] Validate source-text, context, and semantic-key conventions. -- [ ] Validate cardinal and ordinal plural categories per locale. -- [ ] Validate combined context/plural variants. -- [ ] Confirm placeholder compatibility in every locale-specific variant. -- [ ] Type-check keys, translator options, interpolation values, and `count`. -- [ ] Prove the source parser recognizes every project translator API before - enabling `unused` or `undefined`. -- [ ] Test natural keys containing punctuation, especially `:` and `.`. -- [ ] Review every ignore or exclude exception. -- [ ] Run focused runtime tests for locale switching, fallback, lazy resources, - formatting, and rich messages. -- [ ] Run the blocking validation in CI. diff --git a/.agents/skills/logging/SKILL.md b/.agents/skills/logging/SKILL.md deleted file mode 100644 index eabedde..0000000 --- a/.agents/skills/logging/SKILL.md +++ /dev/null @@ -1,327 +0,0 @@ ---- -name: logging -description: Use whenever work adds, changes, reviews, debugs, or consumes application logging in web, React Native, Electron, Electrobun, or another client runtime. Trigger for logger calls, structured log or event records, child loggers, scopes and context, log levels, console usage, transports, browser or native persistence, Electron renderer-to-main logging, logging RPC, batching, buffering, flushing, file logs, logger call sites, origin stacks, source maps, symbolication, Sentry or OpenReplay integration, telemetry breadcrumbs, tracked application events, or deciding where logging infrastructure belongs, even if the request only mentions diagnostics, traces, persisted logs, analytics events, or replacing console calls. ---- - -# Logging - -Use one structured logging contract and facade across client runtimes. Keep -platform delivery behind transports so application code does not know whether a -record goes to a console, browser storage, a native file, or another process. - -Logging records diagnostic facts. It does not decide how an error is handled, -whether a user sees feedback, whether an operation retries, or whether an -exception becomes a tracked incident. - -Keep the failure stack and the logging origin distinct. An error stack answers -where the failure was created or thrown. An origin stack answers where a log or -asynchronous operation was initiated. Preserve both without rewriting either. - -Explicit application events may use the same facade and transport pipeline when -the record keeps its event identity. Do not infer analytics events by parsing -ordinary human-readable log messages. - -## Inspect Before Extending - -Before changing logging: - -1. Find the public logger facade and its factory. -2. Find where the root logger is configured for each runtime. -3. Find registered transports and their lifecycle. -4. For desktop applications, trace renderer-to-host delivery and identify which - runtime owns file access. -5. Check whether the repository already has log persistence, retention, - telemetry breadcrumbs, or an established privacy policy. -6. Check how development and production stacks are source-mapped or - symbolicated for every runtime and release channel. - -Extend the existing foundation when it preserves the boundaries below. Do not -create a second application logger for one feature or runtime. - -## Keep One Contract, Not One Runtime Instance - -Main, renderer, preload, workers, browser pages, and React Native JavaScript run -in separate environments. They cannot share one physical singleton. - -Provide: - -- one platform-neutral logger contract; -- one logger factory and facade behavior; -- one process-local root logger per runtime; -- platform transports selected at the runtime's composition root. - -Place that contract at the narrowest real common owner. A monorepo with several -runtimes may justify a package; a single React Native application may only need -`core/logging`. Do not manufacture a package boundary that has no consumers. - -Feature code imports only its configured logger facade. It must not import file -writers, RPC clients, telemetry SDKs, or storage adapters merely to emit a log. - -## Use A Direct Record Flow - -Keep the core pipeline small: - -```text -logger method - -> merge child scope - -> create one structured log or event record - -> fan out to configured transports -``` - -Do not insert generic normalization, conversion, sanitization, enrichment, or -middleware chains. A transport may perform the smallest representation change -required by its own boundary. Keep that change inside the transport. - -Read `references/facade-and-records.md` when defining or changing the logger -contract, record shape, child behavior, messages, levels, or error argument. - -## Separate Stable Scope From Event Context - -Use `child(...)` for values repeated by several records: - -```ts -const log = logger.child({ - module: "authentication", -}) - -const refreshLog = log.child({ - operation: "refresh-session", - requestId, -}) -``` - -Pass values belonging to one event to the log method: - -```ts -refreshLog.info("Session refreshed", { - userId, - expiresAt, -}) -``` - -When the facade supports explicit tracked events, use the dedicated method: - -```ts -const mtlsLog = logger.child({ module: "mtls" }) - -mtlsLog.event("mtls_provision", { - method: "jwt", -}) -``` - -The event name is a stable machine contract. It is not an ordinary `info` -message reinterpreted later by a telemetry transport. - -Keep scopes small and mostly flat. Prefer identifiers and operational metadata -over entire entities or live runtime objects. - -Snapshot the scope and event context shallowly when creating a logger or record -so later caller mutation cannot change a queued record. Do not deep-clone or -walk values; callers should provide small, boundary-compatible fields. - -Use stable human-readable messages and put variable values in context: - -```ts -// Avoid -log.info(`Project ${projectId} opened`) - -// Prefer -log.info("Project opened", { projectId }) -``` - -## Choose Levels By Meaning - -- `debug`: detailed diagnosis that may be disabled or dropped. -- `info`: a meaningful lifecycle event, state transition, or completed action. -- `warn`: an unexpected but recoverable state or an applied fallback. -- `error`: a failed operation or diagnostic error event. - -Do not log every function call or every successful request. Logging volume must -remain useful enough to inspect. - -Let each transport choose its own level threshold. Feature code must not know -whether a particular environment persists `debug`, `info`, or only higher -levels. - -An explicit application event is a separate record kind, even when its local -console or file representation uses the `info` level. - -## Preserve Diagnostic Origins - -Do not mistake the logger implementation frame for the real call site. -Capturing a stack inside a transport is too late: it points to the transport, -queue flush, RPC handler, or file writer. - -- Keep an original `Error` value unchanged in the local record. -- Capture an optional origin stack synchronously at the public logger call or - before scheduling work that will finish across an async boundary. -- Perform automatic capture in the public method itself. Capturing in a shared - private emitter or origin resolver leaves that helper as the leading frame. -- Store the origin separately, for example as `originStack`; never append it to - `error.stack`. -- Let infrastructure observers explicitly suppress automatic capture when they - have no earlier origin. A Query cache callback, global error listener, RPC - handler, and file writer must not label their observation stack as the - operation origin. -- Preserve a received renderer or worker origin when a host persists the - record. Do not replace it with the host ingestion stack. -- Do not remove frames by a fixed `split(...).slice(n)` rule. Stack formats and - wrapper depth differ between V8, JavaScriptCore, and Hermes. -- Do not capture a stack for every production `debug` or `info` record without - measuring the cost. Configure a deliberate policy, commonly all enabled - levels in development and `warn`/`error` or explicitly traced operations in - production. - -Read `references/trace-origins.md` whenever work touches stack traces, logger -call sites, async origins, source maps, symbolication, Query or Mutation -diagnostics, or cross-runtime log delivery. - -## Compose Platform Transports At Bootstrap - -The logger facade should exist before optional platform services initialize. -Keep a console transport available as the early and emergency fallback, then -register persistence, RPC, or telemetry transports at bootstrap. - -- Web may use console, browser persistence, and remote transports. -- React Native may use console, native persistence adapters, and telemetry. -- Electron or Electrobun main may use console and native file transports. -- Electron or Electrobun renderer may use console and a buffered RPC transport - that delivers records to the host-owned file transport. -- Tests may use an in-memory or no-op transport. - -Provider-specific telemetry transports belong to the provider integration -module. For example, an OpenReplay renderer package may expose a transport that -maps explicit event records to `trackEvent`, while application features remain -unaware of OpenReplay. If the repository deliberately defines every `error` -record as incident-worthy, the provider transport may also map those records to -`captureException`. That choice makes `logger.error` part of the incident -contract: do not keep a second reporting facade that captures the same failure. -If diagnostic errors and incidents differ, represent that distinction -explicitly instead of guessing from a message. - -Read `references/platform-transports.md` when adding transports, file -persistence, Electron renderer-to-main delivery, batching, flushing, or -transport lifecycle. - -Read `references/proven-platform-patterns.md` for compact web, React Native, -Electron, and Electrobun implementation shapes and the edge cases they expose. - -## Keep Transports Isolated - -A transport must never break application behavior or prevent another transport -from receiving a record. - -- Keep logger methods synchronous from the caller's perspective. -- Put queues and batching inside asynchronous transports, not the core logger. -- Bound every queue. -- Make registration idempotent and removable for tests, HMR, and teardown. -- Support best-effort `flush()` and `dispose()` where a transport needs them. -- Report a broken transport directly through a guarded console fallback, not - through the same logger. - -Do not hide an unbounded pre-initialization queue in the logger. Early records -may go only to the console until optional transports are ready. - -## Keep Platform Boundaries Honest - -In a desktop renderer, file access belongs to the privileged host. Deliver -structured records through the existing native-RPC foundation instead of -granting renderer code filesystem access. - -Application code still calls the common facade. The RPC client is an -implementation detail of the renderer transport, not the public logger used by -features. - -Prefer one batch ingestion method over duplicating `debug`, `info`, `warn`, and -`error` as RPC methods. The host must persist received renderer records without -re-logging them through its own root logger; re-logging changes source metadata, -timestamps, and can create loops or duplicates. - -Register logging RPC in the native handler registry, but do not expose it as an -agent capability merely because the repository uses the same registry for both. -Operational transport methods and user- or agent-invokable capabilities have -different semantics. - -## Do Not Build An Error Conversion System - -The logger may accept an `unknown` error value for local diagnosis. The logging -core must not inspect error classes, extract domain fields, traverse custom -causes, or maintain error-type registries. - -Local transports may use the original value. A transport that requires a wire -or persistent representation owns a small, explicitly lossy representation. For -an actual `Error`, it may preserve the standard text and stack directly. For -another value, use a small fallback such as `JSON.stringify`, with a final -string fallback if encoding throws. Keep a separately captured `originStack` -separate on the wire. Do not inspect domain fields or reconstruct an error -instance on the receiving side. - -If a known diagnostic value matters, the caller that knows its meaning should -pass it explicitly in context. Adding a new application error type must not -require editing logging infrastructure. - -## Do Not Promise Automatic Secret Cleanup - -Do not pass passwords, tokens, cookies, private keys, raw authorization -payloads, or unnecessary personal data to the logger. - -Do not invent a recursive sanitizer and rely on it to make unsafe logging safe. -If a repository or telemetry SDK already provides a proven boundary safeguard, -preserve it as defense in depth without turning it into a general application -error-conversion pipeline. - -## Keep Observability Concerns Distinct - -- Sentry-style breadcrumbs may be implemented as a logging transport. -- Decide whether `logger.error` means a diagnostic error or an incident-worthy - error. Do not automatically capture every error unless the repository makes - that contract explicit. -- When an incident provider is a logger transport, emit one eligible record and - let normal fan-out reach local persistence and the provider. Do not call the - provider separately from the same `reportError` flow. -- Stable typed application events may travel through the logging pipeline when - the facade and record distinguish them from ordinary logs. -- Metrics, timings, and analytics events must not be derived from human log - messages or an `info` level alone. -- Provider session control such as initialization, user identity, consent, and - reset is not a log transport and remains in the provider integration. -- User feedback is UI behavior, not a logging transport. - -## Verify The Result - -Before finishing logging work, verify that: - -- feature code imports only the configured logger facade; -- child scope and event context remain distinct; -- explicit application events remain distinguishable from ordinary logs; -- queued records cannot change when the caller later mutates its scope or - context object; -- messages are stable and dynamic values are structured; -- transports are registered once and fail independently; -- asynchronous transports have bounded queues and a flush policy; -- browser or Storybook execution does not instantiate a native transport when - its bridge is absent; -- renderer persistence crosses the established native boundary; -- the host preserves the renderer record rather than re-logging it; -- an error stack and a separately captured origin stack remain distinct; -- origin capture happens before async, queue, worker, or RPC boundaries; -- persisted and remote production stacks are symbolicated against artifacts - from the exact application release or update; -- existing persisted-log schemas remain readable or have an explicit migration; -- infrastructure observers can suppress misleading automatic origin capture; -- transport failures cannot recurse through the logger; -- no new normalization, sanitizer, or error-type registry was introduced; -- sensitive values are absent from records and transport payloads. - -## Related Skills - -- Native RPC contracts, handlers, renderer clients, and host registration -> - **native-integration**. -- Placement of the logging package, platform entrypoints, and local helpers -> - **file-structure**. -- Query and mutation ownership remains in **api-integration**; logging a request - does not move cache or error-handling responsibilities into this skill. -- Catch boundaries, reporting policy, retries, cancellation, and typed error - outcomes → **error-handling**. -- Error, success, warning, fallback, and recovery presentation → - **user-feedback**. diff --git a/.agents/skills/logging/agents/openai.yaml b/.agents/skills/logging/agents/openai.yaml deleted file mode 100644 index 53896fb..0000000 --- a/.agents/skills/logging/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Logging" - short_description: "Structured cross-platform application logging" - default_prompt: "Use $logging to design or update structured logging across web, React Native, Electron renderer, and Electron main runtimes." diff --git a/.agents/skills/logging/references/facade-and-records.md b/.agents/skills/logging/references/facade-and-records.md deleted file mode 100644 index baf2479..0000000 --- a/.agents/skills/logging/references/facade-and-records.md +++ /dev/null @@ -1,368 +0,0 @@ -# Logger Facade And Records - -Use this reference when defining or changing the public logger, record shape, -child behavior, message conventions, levels, or error argument. - -## Contents - -- [Minimal Contract](#minimal-contract) -- [Root And Child Loggers](#root-and-child-loggers) -- [Scope And Context](#scope-and-context) -- [Stable Messages](#stable-messages) -- [Explicit Application Events](#explicit-application-events) -- [Level Semantics](#level-semantics) -- [Error Values](#error-values) -- [Diagnostic Origins](#diagnostic-origins) -- [Transport Dispatch](#transport-dispatch) - -## Minimal Contract - -Adapt names to the repository, but preserve the separation between stable scope -and event-specific context: - -```ts -type LogLevel = "debug" | "info" | "warn" | "error" -type LogKind = "log" | "event" - -type LogScope = Record<string, unknown> -type LogContext = Record<string, unknown> - -interface LogRecord { - timestamp: number - kind: LogKind - level: LogLevel - message: string - scope: LogScope - context?: LogContext - error?: unknown - originStack?: string -} - -interface Logger { - debug(message: string, context?: LogContext): void - info(message: string, context?: LogContext): void - warn(message: string, context?: LogContext): void - error(message: string, error?: unknown, context?: LogContext): void - event(name: string, context?: LogContext): void - child(scope: LogScope): Logger -} -``` - -This is illustrative rather than a mandatory literal type. Reuse an established -contract when it represents the same behavior. - -## Root And Child Loggers - -Create one root logger for each runtime: - -```ts -const logging = createLogging({ - scope: { - runtime: "electron-renderer", - applicationVersion, - sessionId, - }, - transports: [consoleTransport], -}) - -export const logger = logging.logger -``` - -Runtime metadata belongs to the root configuration. Feature code should not -repeat it. - -A child creates another immutable facade with merged scope: - -```ts -const moduleLog = logger.child({ - module: "projects", -}) - -const operationLog = moduleLog.child({ - operation: "open-project", - requestId, -}) -``` - -Creating a child must not create another transport registry, queue, timer, or -file handle. All descendants dispatch through the same runtime logging -instance. - -Snapshot the supplied root and child scope objects shallowly. Otherwise a -caller can mutate an object after creating the logger and silently rewrite the -scope observed by a delayed transport: - -```ts -const scope = { module: "projects" } -const log = logger.child(scope) - -scope.module = "unrelated" // Must not alter `log`. -``` - -Use a child when several records share the value: - -```ts -const syncLog = logger.child({ - module: "sync", - operationId, -}) - -syncLog.info("Synchronization started") -syncLog.debug("Synchronization batch received", { itemCount }) -syncLog.info("Synchronization completed", { durationMs }) -``` - -Do not create a child merely to emit one line: - -```ts -logger.info("Project opened", { projectId }) -``` - -## Scope And Context - -Typical scope fields include: - -- runtime; -- module or capability; -- component or process; -- operation; -- request, correlation, session, or task identifier. - -Keep scope values small and suitable for transport. Do not attach service -clients, stores, React values, request objects, whole entities, or arbitrary -class instances. - -Context belongs to one record: - -```ts -log.info("Workspace loaded", { - workspaceId, - projectCount, - durationMs, -}) -``` - -The logging core should merge scope and preserve context without walking, -normalizing, or enriching their values. Take a shallow snapshot of event -context when creating the record because an asynchronous transport may flush -later. This only protects the top-level record fields; do not deep-clone nested -objects. - -## Stable Messages - -Keep messages readable and stable: - -```ts -// Avoid: every id produces a different message. -log.info(`Workspace ${workspaceId} loaded in ${durationMs}ms`) - -// Prefer: the message groups naturally; values remain queryable. -log.info("Workspace loaded", { - workspaceId, - durationMs, -}) -``` - -Do not require a second machine event name for every log. Metrics and analytics -events must not be inferred by parsing log messages. - -## Explicit Application Events - -When an application sends stable domain events to OpenReplay or another -telemetry provider, the configured logger may expose a distinct `event` -operation: - -```ts -const log = logger.child({ module: "mtls" }) - -log.event("mtls_provision", { - method: "jwt", -}) -``` - -The resulting record must retain `kind: "event"` across asynchronous queues and -renderer-to-host boundaries. Console and file transports may store it alongside -ordinary logs. A telemetry transport can forward it without guessing from the -message or level. - -Do not forward every `info` record as analytics. That silently changes -diagnostic text into a remote data contract, increases telemetry volume, and -makes harmless message edits alter dashboards. - -## Level Semantics - -### Debug - -Use for high-volume details needed while investigating behavior: - -```ts -log.debug("Request batch scheduled", { - requestCount, - delayMs, -}) -``` - -Debug records may be disabled, sampled, or dropped by a transport. - -### Info - -Use for meaningful lifecycle and business-operation milestones: - -```ts -log.info("Workspace opened", { workspaceId }) -``` - -Do not log every render, selector, helper call, or successful network request. - -### Warn - -Use when the application continued despite unexpected or degraded behavior: - -```ts -log.warn("Cached configuration unavailable; defaults applied", { - configurationId, -}) -``` - -A warning should communicate what degraded or what fallback was selected. - -### Error - -Use for a failed operation or diagnostic error event: - -```ts -log.error("Workspace synchronization failed", error, { - workspaceId, - operationId, -}) -``` - -This call records a failure. It does not mean the failure was handled, shown to -the user, retried, or reported as an incident. - -## Error Values - -Keep the public error parameter `unknown`. Do not require errors to inherit from -one application base class. - -The core logger passes the value to local transports unchanged. If a transport -must encode it, keep the fallback local and deliberately small: - -```ts -function stringifyError(error: unknown): string | undefined { - if (error === undefined) { - return undefined - } - - try { - const serialized = JSON.stringify(error) - return serialized && serialized !== "{}" ? serialized : String(error) - } catch { - try { - return String(error) - } catch { - return "[unserializable error]" - } - } -} -``` - -`JSON.stringify(new Error("failed"))` commonly produces `{}` because standard -error fields are not enumerable. Falling back to `String(error)` in that case -preserves the basic message without introducing a universal serializer, -middleware chain, or error registry. - -When a field is important and the caller knows its semantics, provide it -explicitly: - -```ts -log.error("Request failed", error, { - requestId, - status, -}) -``` - -Do not teach the logger to discover `status`, `details`, response bodies, or -domain-specific properties from arbitrary errors. - -## Diagnostic Origins - -An optional `originStack` belongs to the record itself, not ordinary event -context. It describes where the facade was called or where later asynchronous -work was initiated. - -Capture it in the public method before dispatch: - -```ts -function error( - message: string, - error?: unknown, - context?: LogContext, - options?: LogCallOptions, -): void { - const captureOrigin = - options?.captureOrigin ?? captureOriginForConfiguredPolicy("error") - const originStack = - options?.originStack ?? - (captureOrigin ? captureTraceOrigin().stack : undefined) - - dispatch({ - timestamp: Date.now(), - kind: "log", - level: "error", - message, - scope, - ...(context === undefined ? {} : { context: { ...context } }), - ...(error === undefined ? {} : { error }), - ...(originStack === undefined ? {} : { originStack }), - }) -} -``` - -Do not capture inside `dispatch()` or `transport.write()`: the first meaningful -frame will already be logging infrastructure. Do not concatenate the result -with `error.stack`. - -Do not hide the capture in a generic private emitter or origin resolver either: -that helper becomes the leading frame. An observer with no real operation -origin passes `{ captureOrigin: false }`; ordinary feature calls rely on the -configured runtime policy. - -Keep capture policy at bootstrap so development can retain more detail than a -high-volume production runtime. Read `trace-origins.md` for portable capture, -async origins, native boundaries, and source-map requirements. - -## Transport Dispatch - -The core behavior can remain conceptually small: - -```ts -function emit( - level: LogLevel, - message: string, - context?: LogContext, - error?: unknown, -): void { - const record: LogRecord = { - timestamp: Date.now(), - kind: "log", - level, - message, - scope, - ...(context === undefined ? {} : { context: { ...context } }), - ...(error === undefined ? {} : { error }), - } - - for (const transport of transports) { - try { - transport.write(record) - } catch { - reportTransportFailureOnce() - } - } -} -``` - -Do not copy this literally when the repository already owns the facade. The -important properties are one record, one fan-out step, independent transports, -and no generic processor chain between them. diff --git a/.agents/skills/logging/references/platform-transports.md b/.agents/skills/logging/references/platform-transports.md deleted file mode 100644 index f23b5ea..0000000 --- a/.agents/skills/logging/references/platform-transports.md +++ /dev/null @@ -1,339 +0,0 @@ -# Platform Transports - -Use this reference when adding or changing transport registration, browser or -native persistence, Electron or Electrobun renderer-to-host delivery, batching, -flushing, or teardown. - -## Contents - -- [Transport Contract](#transport-contract) -- [Early Logging](#early-logging) -- [Transport Failure](#transport-failure) -- [Web](#web) -- [React Native](#react-native) -- [Electron And Electrobun](#electron-and-electrobun) -- [File Persistence](#file-persistence) -- [Stack And Origin Preservation](#stack-and-origin-preservation) -- [Telemetry](#telemetry) -- [Tests](#tests) - -## Transport Contract - -Keep the caller-facing logger synchronous. An asynchronous transport owns its -queue and lifecycle: - -```ts -interface LogTransport { - write(record: LogRecord): void - flush?(): Promise<void> - dispose?(): Promise<void> -} -``` - -Register transports at the runtime composition root: - -```ts -const logging = createLogging({ - scope: { - runtime: "web", - }, - transports: [createConsoleTransport()], -}) - -const removePersistence = logging.addTransport( - createBrowserPersistenceTransport(), -) -``` - -`addTransport()` should return a removal function or another clear lifecycle -handle. Prevent duplicate registration during HMR, repeated bootstrap, tests, -or remounting. - -## Early Logging - -Make the configured facade importable before optional services initialize. -Keep a console transport available from the start. - -Do not add an unbounded hidden queue for records emitted before persistence or -RPC is ready. It is acceptable for early records to reach only the console. - -If an application must retain early records, make that bounded bootstrap buffer -an explicit project decision with a clear ownership and flush point. - -## Transport Failure - -One transport failure must not: - -- throw through the logger call; -- prevent another transport from receiving the record; -- recursively invoke the same logger; -- retry forever; -- grow an unbounded queue. - -A transport may emit one guarded, rate-limited `console.warn` describing its -own failure. Do not route that warning through the logger it is servicing. - -## Web - -A web runtime may compose: - -- a developer console transport; -- IndexedDB or another browser persistence transport; -- a remote logging or telemetry transport. - -Browser persistence should own its batching, retention, querying, export, and -cleanup behavior. Do not place IndexedDB knowledge in the logger facade. - -Treat an existing persisted schema as a compatibility contract. If the common -record changes from fields such as `t` and `tags` to `timestamp`, `scope`, and -`context`, adapt new records inside the persistence transport or perform an -explicit database migration. Do not silently make old IndexedDB records -unreadable. - -Treat page shutdown flushes as best effort. Use the repository's established -page lifecycle integration rather than claiming that every queued record is -durable. - -## React Native - -React Native usually presents one JavaScript runtime to application code, even -when persistence ultimately crosses a native module. - -Hide that bridge inside the React Native transport: - -```text -application logger - -> React Native transport - -> native logging or file capability -``` - -Feature code still imports the common configured facade. It must not call the -native persistence module directly for ordinary logging. - -Flush buffered records on the project's established application-background or -shutdown lifecycle when useful, without blocking user-visible transitions. - -Verify the filesystem API's relocation semantics before implementing rotation. -For example, APIs in which `move` mutates the source `File` object's URI can -accidentally make subsequent writes target the backup. Copying to the backup -and truncating the current file may be safer for that API. This is a -transport-specific decision, not logger-core behavior. - -## Electron And Electrobun - -Treat the privileged host and renderer as separate runtimes: - -```text -renderer feature - -> renderer logger facade - -> renderer console transport - -> bounded RPC transport - -> host logging handler - -> host-owned file transport - -host feature - -> host logger facade - -> host console transport - -> host-owned file transport -``` - -The host owns file paths, file creation, rotation, retention, reading, and -export. Do not grant the renderer filesystem access merely for logging. - -Use the repository's existing native-RPC foundation. The RPC client belongs -inside the renderer transport; feature modules do not import it. - -Only install the native transport when the bridge is actually available. -Storybook, browser previews, tests, or SSR may import the same configured -facade without a desktop host; those runtimes should retain console logging -without repeatedly failing native requests. - -### Batch Contract - -Prefer one ingestion method carrying a batch of structured records: - -```ts -const loggingMethods = { - writeBatch: "logging.writeBatch", -} as const - -interface WriteLogBatchParams { - records: RendererLogRecord[] -} -``` - -Reuse the repository's contract and validation system. Do not hand-maintain -parallel request shapes when a source of truth already exists. - -Register the method as ordinary native infrastructure. Do not expose log -ingestion to an agent or user-facing capability catalog unless a separate, -explicit product requirement calls for it. - -The wire record should preserve `originStack` and, for an actual error, a -minimal `errorText` and `errorStack`. Keep them as separate fields. Do not send -an `Error` instance, concatenate stacks, or introduce a generic domain error -serializer. - -The transport may apply the smallest wire-only representation change required -by the RPC implementation. Do not place that conversion in the core logger or -reuse it as a general application error model. - -### Renderer Queue - -The renderer RPC transport should: - -- preserve record order within a batch; -- cap its queue; -- flush on a short interval or batch-size threshold; -- avoid one RPC request per ordinary record; -- prefer dropping old `debug` records before more important records when full; -- expose a best-effort `flush()`; -- stop timers and reject new persistence work after `dispose()`; -- keep console logging available when RPC is unavailable. - -Exact batch sizes and intervals depend on the application. Keep them -configurable beside the transport rather than spreading constants through -feature code. - -### Host Ingestion - -The host handler receives records from the renderer and sends them directly to -the host-owned persistence sink. - -Do not call the host root logger again: - -```ts -// Avoid: creates a host record from a renderer record. -mainLogger.info(record.message, record.context) - -// Prefer: preserve the received renderer record. -fileTransport.write(record) -``` - -Re-logging can: - -- replace the renderer runtime with the host runtime; -- assign a second timestamp; -- duplicate console output; -- apply level filtering twice; -- create a loop when transports are composed incorrectly. - -The host's own application logs continue through its host root logger. - -### Infrastructure Failures - -Native RPC may itself need logging. Do not make successful delivery through the -same RPC channel the only way to diagnose its failure. - -Keep direct guarded console output available inside the logging and native-RPC -infrastructure. Avoid a cycle where an RPC failure logs through the failing RPC -transport indefinitely. - -## File Persistence - -The file transport owns: - -- record formatting, such as JSONL or readable text; -- file location; -- append and flush behavior; -- rotation and retention; -- file read/export capabilities; -- platform-specific filesystem errors. - -Reuse a public application-data path provider when one exists. Do not import a -different package's private runtime helper. When no public provider exists, -keep a minimal platform resolver inside the host transport or inject one from -the host composition root. - -Keep the stored representation stable enough for inspection, but do not turn -the file transport into a universal object or error serializer. - -Errors need a small boundary representation. Preserve the standard text and -stack of an actual `Error`; use the established small string fallback for other -values. Preserve an existing `originStack` independently. Scope and context -should already contain plain diagnostic values; if they are circular or -unsupported by the wire, dropping that record is preferable to adding a -recursive sanitizer to the shared logger. - -## Stack And Origin Preservation - -The transport must not invent a later origin: - -- a browser persistence transport keeps the facade-captured origin; -- a React Native native-file transport keeps the JavaScript error and origin - strings without depending on private LogBox or Metro APIs; -- a renderer RPC transport keeps renderer stacks; -- a desktop host writes received renderer stacks directly instead of replacing - them with handler or file-writer frames; -- a host-side failure remains a separate host error correlated by a safe request - identifier. - -Production persistence is useful only when the corresponding web, Node, -Hermes, or native source maps and symbols can be matched to the exact release -or OTA update. Read `trace-origins.md` for the capture helper, platform details, -symbolication, and verification matrix. - -## Telemetry - -A telemetry breadcrumb transport may receive ordinary structured records. -Apply the SDK's established filtering and lifecycle at that boundary. - -An application-event transport should receive only records explicitly marked -as events: - -```ts -const openReplayTransport: LogTransport = { - write(record) { - if (record.kind !== "event") return - - tracker.event(record.message, { - ...record.scope, - ...record.context, - level: record.level, - timestamp: new Date(record.timestamp).toISOString(), - }) - }, -} -``` - -Keep this transport in the provider integration package and register it at the -runtime composition root. Provider initialization, user identity, consent, and -reset remain explicit provider operations rather than logger methods. - -Do not automatically translate every `error` record into an exception incident. -Exception capture has different semantics and belongs to error reporting. - -Do not derive counters, timings, or analytics events from ordinary log messages -or levels. Use explicit typed instruments or event records. - -## Tests - -Use an in-memory transport to assert records without mocking the console: - -```ts -function createMemoryTransport() { - const records: LogRecord[] = [] - - return { - records, - write(record: LogRecord) { - records.push(record) - }, - } -} -``` - -Verify: - -- child scopes merge without mutating their parents; -- one method call creates one record; -- one broken transport does not block another; -- registration and removal are deterministic; -- queue limits and drop policy are enforced; -- flush sends the remaining batch; -- host ingestion preserves renderer metadata and timestamps. -- original error stacks remain unchanged after repeated transport delivery; -- origin stacks survive queues and renderer-to-host delivery as separate data; -- telemetry transports ignore ordinary records unless their policy explicitly - includes them; -- event identity survives any RPC or persistence boundary. diff --git a/.agents/skills/logging/references/proven-platform-patterns.md b/.agents/skills/logging/references/proven-platform-patterns.md deleted file mode 100644 index b638c53..0000000 --- a/.agents/skills/logging/references/proven-platform-patterns.md +++ /dev/null @@ -1,162 +0,0 @@ -# Proven Platform Patterns - -Use these compact shapes when adapting the logging foundation to a concrete -client runtime. They are examples of the same contract, not four competing -logger designs. - -## Web - -Place a reusable facade in a package when several workspace modules or -applications can consume it. Keep browser persistence application-owned: - -```text -packages/logger - -> contract, factory, console transport - -apps/web/core/logger - -> configured web root - -> IndexedDB transport -``` - -The IndexedDB transport owns batching, retention, queries, export, and mapping -between the current common record and any legacy stored schema. - -New errors should receive the transport's minimal string fallback before JSONL -export. Preserve an actual error stack and any separately captured origin stack. -Other non-cloneable context still fails best effort; do not add a recursive -common serializer. - -## React Native - -In a single-application repository, prefer an application module over an -artificial workspace package: - -```text -core/logging/index.ts - -> facade and process-local root - -core/logging/transports/native-file.ts - -> buffer, JSONL, rotation, native filesystem -``` - -Initialize once before application features start. Register a best-effort flush -when the established app lifecycle leaves the active state. Skip the native -file transport on web. - -Keep the original `Error` for console and incident providers. Persist its -standard stack separately from a facade- or operation-captured origin. Release -and OTA-update traces require their matching Hermes source maps. Include stable -release correlation in the root scope, such as the application version, -runtime version, and update identifier; an OTA stack without its exact update -identity cannot be matched reliably to an artifact. - -Keep filesystem behavior inside the transport and test the installed API -version. In particular, determine whether move/rename operations mutate the -source object before using them for rotation. - -## Electron - -Use one package with explicit runtime entrypoints: - -```text -logger -├── index.ts -├── native/client.ts -├── native/main/index.ts -└── preload.ts -``` - -- `index.ts` is Electron-free. -- `native/client.ts` configures renderer console plus bounded batch RPC. -- `native/main/index.ts` configures main console plus file persistence and - registers `writeBatch`. -- `preload.ts` owns a console-only process-local root. - -The renderer transport should only be installed when the preload bridge exists, -so browser previews and Storybook remain valid consumers. The main handler -writes received records straight to the file transport, including renderer -error and origin stacks. - -Choose one incident policy. - -```ts -// Diagnostic logger and explicit incident reporter are separate. -log.error("Workspace load failed", error, { workspaceId }) -reporting.captureException(error) -``` - -Use that shape when some error logs are diagnostic-only. Ensure one owner calls -both operations once; do not add a second global observer for the same failure. - -Alternatively, a repository may define every error record as incident-worthy: - -```text -reportError(error) - -> one logger.error record - -> console and file transports - -> incident-provider transport -``` - -In that policy, the provider transport owns `captureException` and receives the -original local `Error`. Application reporting code must not also call the -provider SDK. If only selected errors are incidents, add an explicit record kind -or call option rather than deriving intent from text. - -When the renderer also uses OpenReplay or another session provider, keep its -event adapter in that provider package: - -```text -feature logger.event(...) - -> renderer logger - -> console and file transports - -> OpenReplay event transport -``` - -The provider transport always forwards explicit event records. Under the -repository's incident policy it may also forward eligible error records, -preserving the original `Error`, scope, context, and separate `originStack`. -Create a fallback `Error` only inside the provider boundary when its SDK -requires one and the record contains a non-Error value. - -Initialization, identity, consent, and reset remain provider control operations; -they are not log transports. - -## Electrobun - -Use the same package split, replacing Electron entrypoints with the repository's -Electrobun native-module conventions: - -```text -renderer logger - -> console - -> bounded writeBatch client - -Bun handler registry - -> validate batch - -> host file transport -``` - -Register `writeBatch` in the native handler/module registry. If the repository -also derives agent capabilities from handler definitions, leave logging -unexposed unless it has explicit capability metadata. - -The host file transport may resolve its own application-data location or -receive a public resolver from the composition root. Do not reach into another -package's private filesystem implementation. - -## Shared Proof Obligations - -Across all four variants, verify: - -- shallow scope and context snapshots; -- original local error identity before a boundary; -- boundary-local error text and standard stack without domain conversion; -- origin stacks kept separate from error stacks; -- bounded async queues and deterministic flush; -- independent transport failures; -- stable persisted schemas or explicit adapters; -- no native requests when the native bridge is absent; -- no host re-logging of renderer records. -- matching source maps or symbols for production releases and updates; -- explicit event identity preserved across transports; -- no accidental promotion of ordinary `info` logs into remote events. diff --git a/.agents/skills/logging/references/trace-origins.md b/.agents/skills/logging/references/trace-origins.md deleted file mode 100644 index 3cfc8cc..0000000 --- a/.agents/skills/logging/references/trace-origins.md +++ /dev/null @@ -1,308 +0,0 @@ -# Trace Origins Across Client Runtimes - -Use this reference when logs or reported errors point to logger internals, -catch blocks, Query callbacks, queue flushes, RPC handlers, or minified bundles -instead of the code that initiated the operation. - -## Contents - -- [Keep Three Locations Distinct](#keep-three-locations-distinct) -- [Capture An Origin Portably](#capture-an-origin-portably) -- [Choose A Capture Policy](#choose-a-capture-policy) -- [Preserve Origins In Records](#preserve-origins-in-records) -- [Async And Framework Boundaries](#async-and-framework-boundaries) -- [Web](#web) -- [React Native](#react-native) -- [Electron And Electrobun](#electron-and-electrobun) -- [Production Symbolication](#production-symbolication) -- [Verification](#verification) - -## Keep Three Locations Distinct - -One diagnostic flow can contain three legitimate locations: - -1. **Failure stack**: where an `Error` was created or thrown. -2. **Operation origin**: where the caller initiated work that later crossed an - async, framework, worker, or RPC boundary. -3. **Observation location**: where a cache callback, reporter, transport, or - host received the result. - -The failure and operation origin are useful. The observation location is -usually infrastructure noise. Do not overwrite the first two with the third. - -For an ordinary log without an error, the operation origin is the logger call -site. For an error log, the original error stack remains primary and the logger -origin is supplemental. - -## Capture An Origin Portably - -`Error.stack` exists across common client engines, but its exact string format -and frame names differ. V8 exposes `Error.captureStackTrace`; Hermes and -JavaScriptCore compatibility must not be assumed. - -Use feature detection and keep the raw stack: - -```ts -export interface TraceOrigin { - readonly stack?: string -} - -type ErrorWithStack = { - stack?: string -} - -type ErrorConstructorWithCapture = ErrorConstructor & { - captureStackTrace?( - target: ErrorWithStack, - constructor?: (...args: never[]) => unknown, - ): void -} - -export function captureTraceOrigin(): TraceOrigin { - const target: ErrorWithStack = {} - const ErrorRuntime = Error as ErrorConstructorWithCapture - - if (typeof ErrorRuntime.captureStackTrace === "function") { - ErrorRuntime.captureStackTrace(target, captureTraceOrigin) - return { stack: target.stack } - } - - return { - stack: new Error("Operation origin").stack, - } -} -``` - -The fallback may retain the helper frame. That is better than deleting a fixed -number of lines and corrupting another engine's format. If a repository already -has a tested engine-specific frame filter, keep it at the presentation boundary, -not in the stored record. - -Capture before the discontinuity: - -```ts -const origin = captureTraceOrigin() - -queueMicrotask(() => { - runJob().catch(error => { - reportError(error, { originStack: origin.stack }) - }) -}) -``` - -Capturing inside `catch`, a queue flush, or an RPC handler only records that -observer. - -## Choose A Capture Policy - -Stack capture and source-map lookup have a cost. Do not add it blindly to every -high-volume production record. - -A practical starting policy is: - -- development: capture origins for every enabled level; -- production: capture for `warn`, `error`, and explicitly traced operations; -- error records: always preserve the original error object locally, regardless - of the origin policy; -- sampled or high-volume events: prefer stable scope and correlation identifiers - unless a measured investigation needs stacks. - -Make the policy configurable at the runtime composition root. Feature code must -not contain environment checks solely to decide whether a logger captures its -call site. - -The method-level options should also support an explicit opt-out: - -```ts -interface LogCallOptions { - readonly captureOrigin?: boolean - readonly originStack?: string -} -``` - -Use `captureOrigin: false` for infrastructure observers that have no genuine -earlier operation origin, such as a declarative Query cache callback, a global -error listener, an RPC ingestion handler, or a file writer. Their local stack -is an observation location, not a useful substitute for the caller. - -## Preserve Origins In Records - -Keep the record shape explicit: - -```ts -interface LogRecord { - readonly timestamp: number - readonly level: LogLevel - readonly message: string - readonly scope: LogFields - readonly context?: LogFields - readonly error?: unknown - readonly originStack?: string -} -``` - -Capture `originStack` at the public facade call, not inside `dispatch()` or a -transport. Allow an established reporter or operation wrapper to provide an -earlier origin captured before an async boundary. - -The distinction includes private facade helpers. If a public method delegates -automatic capture to a generic `emit()` or `resolveOrigin()` helper, that helper -becomes the leading frame. Capture directly while executing the public method, -then pass the raw stack into the private emitter. Do not trim the helper by a -fixed line count. - -Do not concatenate: - -```ts -// Avoid -error.stack += `\n--- called from ---\n${originStack}` -``` - -That mutates a potentially shared error, makes retries duplicate frames, and -produces a string that incident SDKs and source-map processors may parse -incorrectly. - -Local console and telemetry transports should receive the original `Error`. -At a serialization boundary, preserve only the standard diagnostic fields -needed by that boundary: - -```ts -interface WireLogRecord { - readonly errorText?: string - readonly errorStack?: string - readonly originStack?: string -} - -function toWireError(error: unknown) { - if (error instanceof Error) { - return { - errorText: `${error.name}: ${error.message}`, - errorStack: error.stack, - } - } - - return { - errorText: stringifyUnknownAtBoundary(error), - } -} -``` - -This is not a domain error serializer. Do not traverse causes, discover custom -properties, or maintain an error-class registry in logging infrastructure. -When extending a stored or wire schema, keep legacy fields readable until the -transport performs an explicit migration; new records can populate -`errorText`, `errorStack`, and `originStack` without rewriting old data. - -## Async And Framework Boundaries - -Modern engines can retain useful frames across some `await` chains, but timers, -event emitters, task queues, cache execution, workers, and RPC create real -discontinuities. Do not rely on one engine's current async-stack behavior as an -application contract. - -- Capture before scheduling a timer, background task, or queued callback when - the initiating caller matters. -- Give concurrent operations separate origins. Never store the latest origin in - one module-level or hook-level mutable variable. -- For TanStack Query, keep the rejection's original error. A stack captured - while defining a hook identifies registration, not necessarily the later - mutation invocation. A global MutationCache can capture synchronously in - `onMutate` and associate the origin with the Mutation instance; read the - TanStack Query reference below. -- For a React render failure, keep both the thrown error stack and React's - component stack; they answer different questions. -- Use safe operation or request identifiers to correlate logs across runtimes. - -Read `../../error-handling/references/tanstack-query.md` for reporting ownership -and mutation invocation details. - -## Web - -Pass an original `Error` as a value to the console transport instead of only -printing its message or preformatted stack string. Browser DevTools can then -inspect the actual error. - -The clickable location of a wrapped `console.*` call may still point to the -transport. Use the record's separately captured `originStack` when the facade -call site matters. Do not depend on DevTools-specific console formatting as the -only persisted diagnostic. - -Production bundles require source maps available to the chosen incident or log -inspection path. If public source maps are unacceptable, upload them privately -to the provider and remove or withhold deployed `.map` files according to the -build system's supported flow. - -## React Native - -Keep the original `Error` for the local console and incident SDK. Do not replace -it with `error.stack` before reporting; that turns the error into plain text and -can make the logger call look like the origin. - -React Native development tools and LogBox are development aids, not production -symbolication. Avoid private LogBox or Metro APIs for trimming or -symbolicating stacks inside application code. - -Hermes release stacks need the matching JavaScript/Hermes source maps. An OTA -update has a different JavaScript artifact from the embedded application build; -upload and identify maps for each update as well as each native build. - -## Electron And Electrobun - -Treat renderer and host stacks as separate runtime evidence. - -Renderer logging should send: - -- the renderer record and timestamp; -- renderer scope and safe correlation identifiers; -- minimal `errorText` and `errorStack`; -- the separately captured renderer `originStack`. - -The host persists those fields unchanged. It must not re-log the record and -replace the renderer origin with the ingestion handler. - -If a native handler itself fails, report the original failure in the host while -the host `Error` still exists. A renderer-facing RPC failure is a separate, -lossy contract and must not pretend to contain the host stack. Correlate both -sides with a safe request identifier when diagnosis needs the full path. - -For Electron main or another Node-based host, enable the runtime's supported -source-map integration before application modules load. Do not globally replace -`Error.prepareStackTrace` or rewrite stack strings unless the repository already -has a tested requirement for it. - -## Production Symbolication - -A captured stack is only an address list until it maps to the exact shipped -code. - -Verify: - -- source maps are generated for every minified or transpiled runtime; -- the release, build, distribution, and OTA update identifiers match the - uploaded artifacts; -- source maps are uploaded before using a synthetic production failure as a - test; -- private maps are not unintentionally published; -- a real test event resolves to original file, line, and function names; -- persisted raw stacks retain enough generated file, line, and column data for - later symbolication. - -Do not claim trace preservation is complete after a development-only console -test. - -## Verification - -Test at least: - -1. a synchronous thrown `Error`; -2. a caught and rethrown identical error; -3. a wrapper error with `cause`; -4. a rejection after an `await`; -5. a timer or queued task with a captured origin; -6. concurrent mutations or tasks with different origins; -7. a renderer record persisted by the host; -8. a host handler failure correlated with its renderer request; -9. a production or preview build symbolicated with its exact artifacts. - -Assert that the original error stack never gains appended sections after -retries, repeated logging, or transport delivery. diff --git a/.agents/skills/manage-project-work/SKILL.md b/.agents/skills/manage-project-work/SKILL.md deleted file mode 100644 index feb2a9d..0000000 --- a/.agents/skills/manage-project-work/SKILL.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -name: manage-project-work -description: "Classify and route project work through the central knowledge-backed workflow. Use when a requested feature, fix, refactor, migration, investigation, operational change, product decision, or architecture change may be significant; when resuming active work after interruption; or when a useful lightweight result has no active or curated owner yet. This is the default project-work router: it decides between lightweight work, a pending capture, a bounded change, and deliberate Wayfinder, then maintains the owning active checkpoint." ---- - -# Manage Project Work - -Choose the least expensive path that preserves important intent, progress, and -evidence. Operate `wfctl` yourself; the maintainer supplies product authority, -corrections, and review decisions rather than managing files or commands. - -## Route the request - -Treat work as significant when it may change observable behavior, domain -meaning, a contract, state, security, reliability, operations, architecture, -ownership, or cross-repository coordination. Size alone is not decisive. - -- **Lightweight:** clearly local and behavior-preserving. Work directly. Offer - a pending capture only when a non-obvious reusable result should survive and - no active change or curated concept already owns it. -- **Bounded significant change:** the outcome can be specified honestly now. - Start one central bundle and use `specify-project-change`. -- **Wayfinder:** a consequential destination is visible, but dependent product - or architecture choices make an honest spec impossible across one session. - Recommend `shape-project-direction`; enter only after maintainer agreement. - -If classification is genuinely ambiguous, explain the material risk, recommend -one route, and ask for the maintainer's choice. Do not force full ceremony onto -trivial work or hide a significant change as lightweight. - -## Start one canonical bundle - -Create the bundle before extended significant-task discussion so the reasoning -survives compaction: - -```sh -wfctl work start <slug> --title "<title>" --mode full|slice|wayfinder -``` - -Start from one leaf for single-repository delivery. Start from knowledge with -no leaf for project-only work, or repeat `--leaf` for known multi-repository -scope. Do not bind guessed leaves merely because Wayfinder may need them later. -Work that turns out to need one gains it later with `wfctl work bind <id>`, run -from that repository's own checkout. - -Run `wfctl work status <id>` and `wfctl work context <id> --stage shape`. -Use the returned bundle in knowledge for records and only the returned code -roots for implementation. Never create a competing spec or issue tracker in a -leaf. - -## Persist material change and discovery - -A turn is material whether it comes from the maintainer or the agent. It is -material when it changes a requirement, constraint, idea, alternative, -decision, rejection, deferral, scope boundary, evidence, risk, question, next -action, or the agent's understanding of the work. - -Before continuing, apply this preservation test: - -> If this newly learned information disappeared, could a fresh session repeat -> material investigation, choose differently, misunderstand the work, or act -> unsafely? - -If yes, append a complete entry to the owning record's `Discovery ledger`. -Record the observation, evidence or missing evidence, implication, applicable -scope or lifetime, and current disposition. Do not constrain discoveries to a -fixed taxonomy. Use the claimed issue during execution, `change.md` during -shaping/direct work/final review, or a linked artifact when the supporting -material is too large; the owning ledger must link that artifact. If the -discovery changes parent scope, acceptance, or decisions, update `change.md` -as well. Preserve invalidated discoveries with a corrected disposition rather -than erasing them. - -Then update the rest of the semantic record and checkpoint: - -1. append a concise proposed, approved, rejected, deferred, or superseded - decision-ledger entry when a choice changed; -2. update affected current state, scope, acceptance, issues, decisions, - progress, evidence, and discovery implications; -3. preserve rationale without copying the chat transcript or turning the - discovery ledger into an activity log; -4. run `wfctl work checkpoint <id>` for bundle-level work, or add - `--issue <issue-id>` for a claimed issue. Supply current state, last - completed action, exact next action, blockers, and actor. - -The checkpoint may mention a discovery ID and its effect on the frontier, but -must not duplicate the discovery. If useful material has no active or curated -owner, follow the pending-capture route instead. - -New bundle schemas require the ledger section. When an entry exists, workflow -context validates its stable `DISC-*` ID and non-empty observation, evidence, -implication, scope, and disposition. Fix malformed entries before continuing; -do not satisfy the gate with placeholders. - -The checkpoint hash binds the record after those edits. Never edit its YAML by -hand. If any owned record changes afterward, `wfctl work context` reports the -checkpoint stale and the agent must refresh it before claiming or closing work. - -After compaction, interruption, or a clean-session start, first run `wfctl work -context --stage resume` without an ID. It auto-selects only when exactly one -active record is bound to the current checkout. If none exists, do not invent -one. If several exist, run `wfctl work status`, present their human outcomes, -and ask the maintainer which one to resume; never guess from recency, branch, or -directory name. - -For the selected record, inspect status and the reported checkpoints, then -read every required file completely, including the entire discovery ledger, -before acting. Continue only in the exact reported code roots and existing -claim. If a binding or checkpoint is invalid, stop and reconcile it rather -than reconstructing state from chat memory. A checkpoint locates the frontier; -it never replaces the required full reads. - -If an upgraded legacy bundle has no structured checkpoint, read its current -record and former Progress/Handoff sections completely, then run `wfctl work -checkpoint` once to adopt the new model. Preserve the old prose as lineage, but -do not maintain a second resume state afterward. - -If a pre-ledger bundle has no `Discovery ledger`, do not fabricate past -discoveries. Add the section when material work next changes that owner and -preserve new discoveries from that point forward; old bundle versions remain -readable for compatibility. - -## Route the active bundle - -- Use `shape-project-direction` only for deliberate Wayfinder. -- Use `specify-project-change` to synthesize bounded work or collapse a clear - map into stable acceptance criteria. -- Use `split-project-change` when approved work needs several dependency-aware - sessions. -- Use `implement-work-item` for exactly one frontier issue. A small bounded - change may be implemented directly from `change.md` after framing approval, - while preserving the same workspace and progress rules. -- Use `verify-project-work` for complete file accounting, spec/implementation - reconciliation, drafting the curated pages, closure, and promotion. - -A framing is recorded with `wfctl work approve <id> --stage framing --by -human:<maintainer-id>`, and a promotion with `wfctl work promote <id> --by -human:<maintainer-id>`, never by editing `maintainer_review` directly. Record -what they answered in the session with `--attested "<their words>" --session -"<where>"`; a typed confirmation and an out-of-band `--token` remain for a -maintainer who wants a receipt you could not have written. A hand-written receipt -fails the completion gate. Closure itself asks them nothing — that is the tool's -to check, and a completion approval is required only where delivery no longer -matches the framing they approved. - -`changes/active/<id>/` is already the knowledge-side living record. There is -no final dump into `raw/`. Closure moves the entire bundle intact to -`changes/promotion/<id>/` while its drafted pages wait on the maintainer, and to -`changes/archive/<id>/` once they land or once there are none. - -## Pending capture - -Do not create a capture when an active change, issue, intake case, -reconstruction, or curated concept already owns the material; update that owner -and its checkpoint instead. When useful lightweight material genuinely has no -owner and the maintainer accepts retaining it, run: - -```sh -wfctl work capture add <slug> --title "<fact to retain>" -``` - -Complete the returned pending capture. It remains non-authoritative until the -knowledge agent routes it to a real destination or discards it through -`wfctl work capture resolve`. Never copy active progress into `changes/inbox/` -and never cite raw or intake material as current truth. diff --git a/.agents/skills/manage-project-work/agents/openai.yaml b/.agents/skills/manage-project-work/agents/openai.yaml deleted file mode 100644 index b83903f..0000000 --- a/.agents/skills/manage-project-work/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Manage Project Work" - short_description: "Route work and preserve resumable context" - default_prompt: "Use $manage-project-work to classify or resume this request, preserve consequential discoveries in their semantic owner, and refresh the owning checkpoint last." diff --git a/.agents/skills/manage-project-work/assets/bundle-review.md b/.agents/skills/manage-project-work/assets/bundle-review.md deleted file mode 100644 index bd0545e..0000000 --- a/.agents/skills/manage-project-work/assets/bundle-review.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -workflow_version: 1 -kind: bundle-review -updated_at: "{{UPDATED_AT}}" -files: [] ---- - -# Bundle review - -This ledger is maintained by `wfctl`. Each receipt accounts for one complete -bundle file at an exact content hash. It is not proof of semantic correctness. diff --git a/.agents/skills/manage-project-work/assets/capture.md b/.agents/skills/manage-project-work/assets/capture.md deleted file mode 100644 index 6c82f9d..0000000 --- a/.agents/skills/manage-project-work/assets/capture.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -capture_version: 1 -kind: capture -id: "{{CAPTURE_ID}}" -title: "{{TITLE}}" -status: pending -created_at: "{{CREATED_AT}}" -awaits: agent -source: {} -claim_refs: [] -resolution: null ---- - -# Summary - -Record the reusable result, proposal, limitation, or observation. Separate -verified facts, maintainer intent, and inference. - -# Evidence - -Record exact source paths, revisions, commands, outputs, and known limits. For -intake or reconstruction material, retain fully qualified claim references in -`claim_refs`; never cite raw files as authority. - -# Who answers this - -State `awaits: maintainer` when the capture holds something only they can -settle — intent nobody recorded, a decision to confirm or revoke, a route -between materially different options. State `awaits: agent` when it is material -someone still has to place. A question filed as triage is a question nobody -knows they were asked. - -# Why retained - -Explain why this material may affect a future product or engineering decision -despite not belonging to active work or curated knowledge yet. - -# Suggested route - -Recommend one next route: discard it, curate verified truth, or start/link a -normal significant change. The capture remains non-authoritative while it is -pending in `changes/inbox/`. diff --git a/.agents/skills/manage-project-work/assets/wayfinder-map.md b/.agents/skills/manage-project-work/assets/wayfinder-map.md deleted file mode 100644 index 61f9844..0000000 --- a/.agents/skills/manage-project-work/assets/wayfinder-map.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -workflow_version: 1 -kind: wayfinder-map -status: charting -destination: "" -notes: [] -resolved: [] -fog: [] -out_of_scope: [] -created_at: "{{CREATED_AT}}" -updated_at: "{{CREATED_AT}}" ---- - -# Destination - -Name what reaching the end of this map makes possible. This is a planning -destination, not an instruction to implement it during Wayfinder. - -# Standing notes - -Record domain, constraints, accepted vocabulary, and skills or sources every -session must consult. - -# Resolved route - -`wfctl` maintains a one-line named pointer for each completed Wayfinder issue. -The issue remains the only home of its full resolution. - -# Not yet specified - -Keep in-scope fog that cannot yet be phrased as a precise question. Remove a -fog item when it becomes an issue or is ruled out of scope. - -# Out of scope - -Record boundaries outside this destination and the reason for each boundary. diff --git a/.agents/skills/manage-project-work/assets/work-issue.md b/.agents/skills/manage-project-work/assets/work-issue.md deleted file mode 100644 index df4086f..0000000 --- a/.agents/skills/manage-project-work/assets/work-issue.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -workflow_version: 3 -kind: work-issue -id: "{{ISSUE_ID}}" -title: "{{TITLE}}" -phase: delivery -type: delivery -status: draft -blocked_by: [] -satisfies: [] -repositories: [] -artifacts: [] -claim: null -resolution: null -created_at: "{{CREATED_AT}}" -updated_at: "{{CREATED_AT}}" -checkpoint_version: 1 -checkpoint: - status: ready - stage: implement - actor: system:wfctl - current_state: Issue is ready but unclaimed. - last_completed: Issue record created. - next_action: Read the required context and claim the issue. - blockers: [] - updated_at: "{{CREATED_AT}}" - basis_sha256: "{{CHECKPOINT_BASIS}}" ---- - -# Outcome - -State the complete behavior, decision, or fact this issue must deliver. Keep it -small enough for one fresh agent session when possible. - -# Acceptance contribution - -Explain how this issue contributes to its `satisfies` acceptance IDs. For a -Wayfinder issue, state the precise question it resolves instead. - -# Constraints and boundaries - -Record relevant project knowledge, approved decisions, repository scope, and -explicit exclusions. Do not copy the parent specification. - -# Discovery ledger - -Preserve newly learned information when losing it could make a future session -repeat material investigation, choose differently, misunderstand the work, or -act unsafely. Do not use this as a chronological activity log or restrict it to -a predefined class of findings. - -Append one durable block per discovery, replacing the placeholders: - -```markdown -## DISC-NNN — Concise title - -- **Observation:** What was learned and its uncertainty. -- **Evidence:** Direct basis or missing evidence. -- **Implication:** What this changes. -- **Scope:** Where and for how long it applies. -- **Disposition:** Its current owner or next destination in plain language. -``` - -Preserve an invalidated entry and update its disposition rather than deleting -it. - -# Verification - -Record direct source inspection, executable checks, outcomes, and limitations. -Graph or search output is navigation evidence, not implementation proof. diff --git a/.agents/skills/manage-project-work/assets/work-spec.md b/.agents/skills/manage-project-work/assets/work-spec.md deleted file mode 100644 index 23118d1..0000000 --- a/.agents/skills/manage-project-work/assets/work-spec.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -workflow_version: 5 -id: "{{WORK_ID}}" -title: "{{TITLE}}" -mode: "{{MODE}}" -status: shaping -scope: leaf -created_at: "{{CREATED_AT}}" -updated_at: "{{CREATED_AT}}" -checkpoint_version: 1 -checkpoint: - status: active - stage: shape - actor: system:wfctl - current_state: Initial framing is pending. - last_completed: Central work bundle created. - next_action: Persist the first agreed framing and refresh this checkpoint. - blockers: [] - updated_at: "{{CREATED_AT}}" - basis_sha256: "{{CHECKPOINT_BASIS}}" -repositories: [] -acceptance: [] -direction: - status: bounded - map: "" - resolved_at: "" -knowledge_alignment: - reviewed: [] - conflicts: [] - # Written by `wfctl knowledge decided "<subject>" --record <id>`, never by hand: - # a check the record claims is one the command actually ran. - decided: null - # Set covered: false with a basis when no curated concept covers this work. - # An empty knowledge base is a supported state; an invented concept path is not. -graph_evidence: - queries: [] -knowledge_promotion: - # Written by `wfctl work promotion <id>`, from the pages drafted under this - # bundle's promotion/ directory. Draft each at the path it will occupy inside - # knowledge/; nothing enters the corpus until the maintainer approves it. - status: "" - concepts: [] - drafts: [] - reason: "" -maintainer_review: - framing: - status: pending - by: "" - at: "" - notes: [] - # Asked only when delivery no longer matches the approved framing. - completion: - status: pending - by: "" - at: "" - notes: [] - # Written by `wfctl work promote <id>`, which writes the pages in the same act. - promotion: - status: pending - by: "" - at: "" - notes: [] -verification: - result: pending - revision: "" - worktree_id: "" - repositories: [] - acceptance: [] - acceptance_reviewed: false - implementation_reviewed: false - knowledge_reviewed: false - checks: [] - unresolved: [] ---- - -# Summary - -State the intended outcome and why it matters. - -# Current state - -Maintain the latest agreed problem, desired outcome, constraints, scope, -assumptions, and risks. Rewrite this section whenever the current understanding -changes; keep resumable execution state only in the structured checkpoint. - -# Direction map - -For a broad initiative, record the destination, current landscape, affected -Areas and actors, constraints, success signals, and explicit non-goals. For a -bounded task, state `Not required — bounded change` with a short reason. - -# Domain language - -Record proposed canonical terms, definitions, accepted aliases, names to -avoid, and their resolution state. Keep unresolved vocabulary here; promote it -to current knowledge only after approval. - -# Decision frontier - -List only unresolved choices whose answers can materially change product -meaning, scope, architecture, ownership, or the next safe action. Rank them by -leverage. For a bounded task with no frontier, state `None`. - -# Uncertainty and fog - -Record unknown facts, missing authority, contradictions, dependencies, and -risks. For each item, state its impact and what would resolve it. - -# Open questions - -- Record unresolved questions that can change the solution, authority, or scope. - -# Discussion and decision ledger - -Append one concise entry after every material maintainer turn. Preserve rejected, -deferred, and superseded directions instead of rewriting history. - -| At | Status | Subject | Outcome and rationale | -| --- | --- | --- | --- | -| {{CREATED_AT}} | proposed | Initial framing | Awaiting discussion. | - -# Current behavior and evidence - -Record Graphify queries, the source locations they led to, and direct inspection -of the actual code. Graph output is navigation evidence, never the authority for -an implementation claim. Add supplementary text-search findings only after -graph analysis. - -# Discovery ledger - -Preserve newly learned information when losing it could make a future session -repeat material investigation, choose differently, misunderstand the work, or -act unsafely. This is an information-acquisition ledger, not an activity log and -not a closed list of "findings" categories. - -Append one durable block per discovery, replacing the placeholders: - -```markdown -## DISC-NNN — Concise title - -- **Observation:** What was learned, including uncertainty when unverified. -- **Evidence:** Direct basis, or the evidence that is still missing. -- **Implication:** What this changes for understanding or action. -- **Scope:** Where it applies and any lifetime or invalidation condition. -- **Disposition:** What now owns it or must happen next, in plain language. -``` - -Preserve superseded or disproven entries and update their disposition instead -of deleting the path by which the team learned. - -# Knowledge alignment - -List relevant vision, product, architecture, decision, repository, and uncertainty concepts. Record conflicts and maintainer resolutions. - -# Scope - -## In - -- Define included behavior. - -## Out - -- Define explicit exclusions. - -# Decisions - -- Record the current approved decisions with enough rationale to guide - implementation. Link each entry to its ledger history when it evolved. - -# Plan and progress - -- [ ] Add concrete implementation and validation steps. - -# Acceptance criteria - -- [ ] Add observable, testable completion criteria. - -# Verification evidence - -Record fresh commands, results, directly inspected code at the bound revision, -and criterion-by-criterion inspection. A passing test suite or Graphify result -alone is not complete evidence. - -# Knowledge promotion - -List the curated concepts updated by this change, or explain why the completed -change does not alter durable project intent, meaning, decisions, contracts, -boundaries, ownership, or operational knowledge. Promote only claims verified -against authoritative sources; untrusted raw intake is never provenance. - -# Deviations and unresolved work - -State deviations, remaining risks, placeholders, mocks, follow-ups, or `None`. diff --git a/.agents/skills/native-integration/SKILL.md b/.agents/skills/native-integration/SKILL.md deleted file mode 100644 index f4460a0..0000000 --- a/.agents/skills/native-integration/SKILL.md +++ /dev/null @@ -1,345 +0,0 @@ ---- -name: native-integration -description: Use whenever work adds, changes, reviews, or debugs desktop native integration in Electron, Electrobun, or another host/webview runtime. Trigger for Electron main-process, preload, contextBridge, ipcMain/ipcRenderer, IPC channels, Electrobun BrowserView/Electroview RPC, native modules, handler definitions, typed renderer clients, method or event registries, capability metadata or catalogs, runtime validation schemas, host registration, filesystem or OS access, deep links, secure storage, updates, and exposing native operations to automation, agents, or MCP. ---- - -# Native Integration - -Treat Electron, Electrobun, and similar desktop runtimes as transport adapters -around the same modular native-RPC architecture: - -```text -shared contract - -> handler definitions - -> native feature module - -> central host registry - -> runtime transport - -> typed renderer client - -handler definitions - -> capability catalog - -> optional automation, agent, or MCP consumers -``` - -The renderer is sandboxed. The host owns privileged work. A typed, validated, -inspectable native registry is the boundary between them. - -First inspect the repository's existing native-RPC foundation and one complete -feature module. Extend that pattern. Do not introduce a parallel raw IPC system. - -## Start From A Feature Module, Not A Channel - -A native integration is a feature with a contract, handlers, metadata, and a -client. It is not merely an IPC string. - -Examples include: - -- filesystem and operating-system access; -- secure storage and keychain operations; -- windows, dialogs, notifications, and shell operations; -- deep links and application lifecycle; -- application updates; -- local services and native addons. - -Each feature owns its contract and runtime implementation. The application -composes features through one registry. Transport-specific wiring must not -become the owner of feature behavior. - -## Prefer Package-Owned Native Features - -If a native feature can be isolated behind a coherent contract, place it in -`packages/`. This is the default, not an optimization reserved for features -already shared by multiple applications. - -The package should own: - -- its shared contract and method registry; -- its handler definitions and host implementation; -- its typed renderer client; -- its runtime-specific entrypoints when required. - -The application should only compose and register the package. Keep a feature -inside an application only when it is genuinely inseparable from that -application. - -An illustrative package may expose surfaces such as: - -```text -packages/{capability}/ -└── src/ - ├── index.ts - └── native/ - ├── index.ts - ├── client.ts - ├── main/ - │ └── index.ts - └── bun/ - └── index.ts -``` - -This is not a mandatory literal tree. Use only the runtime entrypoints the -project needs. Apply **file-structure** recursively to decide exact placement, -capability subfolders, public entrypoints, and local internals. - -Shared and renderer entrypoints must never import host-only dependencies. - -## Define One Shared Contract - -Declare every callable method once in a feature-owned registry: - -```ts -export const nativeMethods = { - chooseDirectory: "workspace:choose-directory", - readMetadata: "workspace:read-metadata", -} as const; -``` - -The contract must provide: - -- a stable, feature-namespaced wire method; -- the request parameters for that method; -- the response for that method; -- runtime schemas for both request and response. - -Prefer deriving TypeScript types from the schemas or another existing source of -truth. Do not separately hand-maintain method strings, DTOs, schemas, host -signatures, and renderer signatures. - -Runtime validation of both parameters and responses is mandatory at the native -boundary. An exception is acceptable only when the established transport -already guarantees the exact runtime-validated contract or the value cannot -meaningfully be represented by the project's schema system. Document that -exception beside the definition. `void` input or output is still an explicit -contract, not an omitted one. - -Do not create a generic `types.ts` dump. Keep each contract with the capability -that owns it. - -## Define Inspectable Handlers - -Every request handler definition should carry enough information to register, -validate, execute, inspect, and expose it without reconstructing knowledge -elsewhere: - -- a stable name; -- its method from the shared registry; -- parameter and response schemas; -- an execution policy, including timeout behavior; -- a host-context factory or explicit dependencies; -- capability metadata. - -Conceptually: - -```ts -const chooseDirectory = defineNativeHandler({ - name: "chooseDirectory", - method: nativeMethods.chooseDirectory, - params: chooseDirectoryParams, - response: chooseDirectoryResponse, - execution: { timeoutMs: 30_000 }, - capability: { - id: nativeMethods.chooseDirectory, - title: "Choose workspace directory", - description: "Opens the native directory picker.", - exposure: false, - requiresApproval: true, - }, - createContext: createWorkspaceContext, - handle: async ({ params, context }) => { - // Privileged feature logic belongs here or in an injected service. - }, -}); -``` - -Names and helper signatures are illustrative. Reuse the repository's native-RPC -primitives instead of recreating this API. - -Validate at the boundary. Keep privileged logic in the handler or an injected -host service, never in preload code or renderer code. - -Use bounded timeouts by default. Disable a timeout only for an operation whose -lifecycle is legitimately open-ended, such as waiting for a native user prompt, -and make that decision explicit. - -## Make Capabilities Universal And Exposure Explicit - -Every registered handler must include capability metadata even when its only -current consumer is the renderer. - -At minimum, metadata should make these facts discoverable: - -- stable capability id; -- human-readable title and description; -- safety or effect classification used by the project; -- whether explicit approval is required; -- whether the capability may be exposed outside the ordinary renderer client. - -Exposure is opt-in. The default must be `false`, `none`, or the repository's -equivalent. Registration makes a handler callable by the trusted application -client; it does not automatically make it available to automation, agents, or -MCP. - -Derive the capability catalog from handler definitions and their schemas. Do -not maintain a second manual registry. Automation, an agent, MCP, a command -palette, or another future consumer may read the catalog, but none of them owns -the native architecture. - -Operations with destructive effects, external side effects, sensitive data, or -arbitrary native-tool execution should require approval according to the -project's policy. - -## Build A Native Feature Module - -A native module aggregates the feature boundary: - -- module name; -- method registry; -- request map; -- handler loader or handler map; -- handler definitions and capability metadata; -- typed renderer client. - -The module must not know which application will register it. Host-only handlers -may load lazily to keep native dependencies out of shared and renderer bundles. - -Registration should fail clearly for duplicate methods, missing handlers, or a -contract that cannot be validated. Do not silently allow incomplete modules. - -## Keep One Reviewable Host Registry - -The host application should have one central registry that composes all native -modules. Adding an isolated feature should require one obvious registry entry, -not edits across unrelated switches and transport files. - -The registry should be able to: - -- combine request contracts; -- load and dispatch handlers by method; -- enumerate handler definitions and capability metadata; -- detect duplicate or missing registrations. - -Register the complete host surface before the renderer or webview can call it. -Application-owned registry code is wiring only; feature logic remains in its -package. - -## Generate A Thin Renderer Client - -Build the renderer client from the same request map and method registry used by -the host. - -The renderer: - -- imports a client-safe entrypoint such as `native/client`; -- calls feature methods, not raw transport channels; -- never imports host entrypoints, native addons, or `node:*`; -- never repeats method strings or request/response types; -- guards native-only access when the project also runs in a browser, test, or - Storybook environment. - -Keep transport errors in their native form unless the project already defines a -specialized error registry or conversion flow. Follow that established flow -when it exists. Never invent a new normalization layer inside a feature client. - -## Adapt Only The Transport - -The feature module and contract remain conceptually the same across runtimes. -Only the adapter changes. - -| Runtime | Host adapter | Renderer boundary | -| --- | --- | --- | -| Electron | Register the combined request map through `ipcMain.handle` or the repository's wrapper | Expose one narrow preload bridge through `contextBridge`; the typed feature client invokes it | -| Electrobun | Register the combined schema and handlers through the project's BrowserView RPC wrapper | Construct the typed client through Electroview RPC or the repository's wrapper | - -For Electron, prefer one shared bridge surface over a new `window.*` global for -every feature. Preload forwards validated typed calls; it contains no feature -business logic. - -For Electrobun, keep Bun-only handlers behind the Bun runtime entrypoint and -webview-safe client code behind the client entrypoint. - -Follow the exact runtime API and versions already installed in the repository. -Do not make feature packages depend on transport details that belong in the -adapter. - -## Treat Requests And Events As Separate Contracts - -Request-response is the default for commands and queries: send typed parameters -and receive one typed result. - -Events are first-class for host-pushed information such as: - -- deep-link navigation; -- update or download progress; -- native lifecycle changes; -- long-running operation progress; -- notifications produced outside a renderer request. - -Define a typed event registry and payload map rather than scattering event -strings. Every renderer subscription must return an unsubscribe function that -removes the exact listener it registered. - -Do not emulate request-response with ad hoc event pairs. Do not emulate a native -event stream with polling or repeated invokes. - -How a component subscribes and manages its lifecycle remains a React concern; -follow **components**, including its strict guidance on avoiding `useEffect`. - -## Preserve The Privilege Boundary - -- The renderer never accesses the filesystem, OS, native addons, keychain, or - host lifecycle directly. -- Validate untrusted request parameters before privileged work and validate the - result before returning it across the boundary. -- Prefer narrow operations over arbitrary primitives: for example, a specific - trusted URL operation instead of unrestricted shell execution. -- Inject host context such as windows, services, paths, and application metadata - rather than importing ambient global state throughout handlers. -- Follow the project's existing logging and error policy. Do not leak secrets, - sensitive native details, or arbitrary host error payloads across the bridge. - -## Implementation Workflow - -1. Inspect the native-RPC foundation, central registry, and one complete native - feature already present in the repository. -2. Decide whether the feature can be isolated into a package. Prefer a package - whenever it can. -3. Define the method registry, request map, and runtime schemas. -4. Implement handler definitions with execution policy, host context, and - capability metadata. -5. Aggregate them into a native feature module. -6. Derive or build the typed renderer client from the same contract. -7. Add one explicit entry to the central host registry. -8. Add a typed event contract only when the host must push information. -9. Test contract validation, handler behavior, registry completeness, client - typing, and subscription cleanup as applicable. -10. Run the repository's focused formatting, linting, type-checking, tests, and - native build checks. - -## Avoid - -- scattered raw `ipcMain`, `ipcRenderer`, or RPC method strings; -- a separate preload global for every feature; -- duplicate request/response types on opposite sides of the boundary; -- handlers without parameter or response validation; -- registered handlers without capability metadata; -- exposing registered handlers to agents, MCP, or automation by default; -- large application-level switches that own feature dispatch; -- renderer imports from host runtime entrypoints; -- host imports leaking into shared or client bundles; -- keeping an isolatable native feature in an application instead of `packages/`; -- a generic native, handlers, or types dumping ground. - -## Related Skills - -- Exact package and feature-internal placement, visibility, and entrypoints → - **file-structure**. -- Native client errors and external-service access → **api-integration**. -- Secure persisted state consuming a native provider → **state-management**. -- Native deep links entering the route tree → **routing**. -- Host and renderer diagnostic delivery → **logging**. -- Native failure propagation, exception reporting, and runtime boundaries → - **error-handling**. -- Native-originated user notifications and recovery presentation → - **user-feedback**. -- Formatting, linting, type-checking, tests, and native build checks → - **code-quality**. diff --git a/.agents/skills/qmd/SKILL.md b/.agents/skills/qmd/SKILL.md deleted file mode 100644 index 0d4b048..0000000 --- a/.agents/skills/qmd/SKILL.md +++ /dev/null @@ -1,295 +0,0 @@ ---- -name: qmd -description: Search local markdown knowledge bases, notes, docs, and wikis with QMD. Use when users ask to find notes, retrieve documents, inspect a wiki, answer from indexed markdown, or set up QMD access. -license: MIT -compatibility: Requires qmd CLI or MCP server. Install via `npm install -g @tobilu/qmd`. -metadata: - author: tobi - version: "2.2.0" -allowed-tools: Bash(qmd:*), mcp__qmd__* ---- - -# QMD - Query Markdown Documents - -## How search works - -QMD searches local markdown collections: notes, docs, wikis, transcripts, and -project knowledge bases. Use it before web search when the answer may already be -in indexed local files. - -The workflow is always: - -1. Search for candidate documents. -2. Retrieve the full source with `qmd get` or `qmd multi-get`. -3. Answer from retrieved text, citing paths or docids. - -Do not answer from snippets alone when the user needs facts, decisions, quotes, -or nuance. Snippets are only leads. - -Typical loop: - -```bash -qmd search "merchant reality support interviews" -n 5 -# leads: #abc123 concepts/customer-proximity.md; #def432 sources/merchant-call.md -qmd multi-get "#abc123,#def432" --format md -``` - -**Default to structured `qmd query` with `intent:`, `lex:`, `vec:`, and `hyde:` -fields that you write yourself.** You are a better query expander than the -built-in model: you know the user's actual goal, the domain vocabulary, and the -nearby-but-wrong concepts to avoid. Do not just paste the user's words into -`qmd query "..."` and hope the expansion model guesses right — supply the -`intent:` and craft the lexical and semantic terms deliberately (see -[Pick the right search mode](#pick-the-right-search-mode)). - -When reporting what you retrieved, a compact note is enough; do not paste whole -files unless needed: - -```text -Retrieved: -- #abc123 concepts/customer-proximity.md -- #def432 sources/merchant-call.md -``` - -## Pick the right search mode - -Use **BM25 lexical search** when you know exact words, titles, names, code -symbols, or rare phrases: - -```bash -qmd search "cockpit OKR Goodhart" -n 10 -qmd search '"AI Before Headcount"' -c concepts -n 5 -``` - -Use **`qmd query` with structured fields** when the user describes an idea -indirectly, uses different wording than the source, or needs conceptual recall. -**This is the default mode — write the fields yourself rather than leaning on -query expansion.** Combine exact anchors with semantic recall: - -```bash -qmd query $'intent: Find the concept note about metrics as instruments without letting OKRs replace judgment.\nlex: cockpit instruments OKR Goodhart metrics judgment\nvec: data informed not metric driven product judgment\nhyde: A concept note says metrics are useful like cockpit instruments, but leaders should remain data-informed rather than metric-driven because OKRs and dashboards can Goodhart product judgment.' -``` - -Structured query fields (you author each one — do not delegate this to the -expansion model): - -- `intent:` states what you are trying to find **and what to avoid**. Always - supply this. It steers ranking away from nearby-but-wrong concepts. -- `lex:` exact terms, aliases, titles, code symbols, and rare words you expect - in the source. This is your own keyword expansion. -- `vec:` paraphrases the idea in natural language, in source-like wording. -- `hyde:` describes the document or answer that would satisfy the request. - -You do not need all four every time, but you should almost always write at least -`intent:` plus one of `lex:`/`vec:`. A bare `qmd query "the user's sentence"` -throws away the context only you have and relies on the built-in expander to -reconstruct it — prefer the structured form. - -If you genuinely have nothing to expand (a single rare token, a verbatim phrase), -that is a job for `qmd search`, not bare `qmd query`: - -```bash -qmd query --format json --explain $'intent: ...\nlex: ...\nvec: ...' # inspect ranking -``` - -If `qmd query` is slow or model/GPU setup fails, fall back to `qmd search` with -better lexical terms. - -## Retrieve sources - -Search results include docids like `#abc123` and `qmd://...` paths. Fetch them: - -```bash -qmd get "#abc123" -qmd get qmd://concepts/ai-before-headcount.md -qmd multi-get "#abc123,#def432" --format md -qmd multi-get 'concepts/{ai-before-headcount.md,data-informed-not-metric-driven.md}' --format md -qmd multi-get 'sources/podcast-2025-*.md' -l 80 -``` - -Use `multi-get` when comparing several hits or gathering context across pages. - -### Output is line-numbered and carries the docid — cite both - -`get` and `multi-get` are **line-numbered by default** and always print the -document's `#docid` and `qmd://` path. So `get` output looks like: - -```text -qmd://concepts/note.md #abc123 ---- - -1: # Metrics as instruments -2: -3: Treat dashboards like cockpit instruments... -``` - -Cite the docid and exact line numbers in your answer, and use the numbers to ask -for the next slice. Pass `--no-line-numbers` only when you need raw content to -copy verbatim (e.g. reproducing a code block). - -When you need to open or edit the underlying file (e.g. hand a path to `Read`, -`Edit`, or an editor), add `--full-path`. It replaces the `qmd://` URL + docid -header with the document's on-disk path, falling back to the canonical header if -the file no longer exists on disk: - -```text -$ qmd get "#abc123" --full-path -/Users/you/notes/concepts/note.md ---- - -1: # Metrics as instruments -``` - -`--full-path` works the same way on `qmd search` and `qmd query`: result paths -become the file's on-disk path — `./`-prefixed relative path when the file is -inside `$PWD`, absolute realpath otherwise — and the per-result `#docid` is -dropped because the path is the identifier. The leading `./` is intentional so -the output is unambiguously a filesystem path and cannot be mistaken for a bare -collection-relative string. Default search/query output still uses `qmd://` -URIs; only opt into `--full-path` when you specifically need a path you can hand -to a non-QMD tool. - -### Read line ranges with the `:from:count` suffix — never pipe through `sed`/`head`/`tail` - -`qmd get` slices files itself. Use the suffix or flags; do **not** shell out to -`sed -n`, `head`, `tail`, or `awk` to pull a line range. Piping defeats docid -resolution, virtual-path lookups, line numbering, and the header, and it is -slower and more error-prone. - -The most compact form is a `:from:count` suffix right on the path or docid — -prefer it: - -```bash -qmd get "#abc123:120:40" # 40 lines starting at line 120 -qmd get qmd://concepts/note.md:200:60 # lines 200–259 -qmd get "#abc123:120" # from line 120 to end of file -qmd get "#abc123" --from 120 -l 40 # equivalent, using flags -``` - -Suffix and flags: - -- `<path>:<from>:<count>` — start at line `<from>`, read `<count>` lines. **Best - for reading around a search hit.** -- `<path>:<from>` — start at `<from>`, read to end of file. -- `--from <line>` / `-l <lines>` — flag equivalents. Explicit flags override the - suffix, so `... :5:2 -l 1` reads 1 line. -- `--no-line-numbers` — drop the `N:` prefixes (line numbers are on by default). - -Wrong: `qmd get "#abc123" | sed -n '120,160p'` -Right: `qmd get "#abc123:120:40"` - -Search results include a `:line` anchor on each hit — feed it straight into -`qmd get path:line:<n>` to read a window around the match (line numbers in the -output will start at `line`). - -## Discover what is indexed - -```bash -qmd collection list -qmd ls -qmd status -``` - -Add collection filters when broad searches drift into the wrong corpus: - -```bash -qmd search "headcount autonomous agents" -c concepts -n 10 -qmd query "merchant support product reality" -c concepts -c sources -n 10 -``` - -Omit `-c` to search everything. - -## MCP Tool: `query` - -When using the MCP server, prefer structured searches: - -```json -{ - "searches": [ - { "type": "lex", "query": "cockpit OKR Goodhart" }, - { "type": "vec", "query": "data informed not metric driven product judgment" }, - { "type": "hyde", "query": "A concept note explains that metrics are useful as instruments, but leaders should not let OKRs or dashboards replace judgment." } - ], - "intent": "Find the concept note about using metrics as instruments without becoming metric-driven.", - "collections": ["concepts"], - "limit": 10 -} -``` - -Query types: - -- `lex` — BM25 keyword search. Best for exact terms, names, titles, and code. -- `vec` — vector semantic search. Best for natural-language concepts. -- `hyde` — vector search using a hypothetical answer/document passage. - -## Query craft - -Good QMD searches mix three things: - -1. **Title/alias anchors:** exact page titles, named entities, phrases. -2. **Semantic paraphrase:** how a human would describe the idea. -3. **Negative space:** enough intent to avoid nearby-but-wrong concepts. - -Examples: - -```bash -# Exact-ish title lookup -qmd search '"arm the rebels" merchants tools big companies' -c concepts - -# Semantic concept lookup -qmd query $'intent: Find the customer proximity concept, not generic customer delight.\nlex: support pseudonymous merchant customer interviews\nvec: founder stays close to merchant reality through support and product use' - -# Source lookup -qmd search "six-week cadence WhatsApp merchant relationships Shawn Ryan" -c sources -n 10 -``` - -## Setup and maintenance - -Only mutate indexes when the user asked for setup or maintenance. Searching and -retrieving are safe; collection/index mutation is not a casual first step. - -```bash -npm install -g @tobilu/qmd -qmd collection add ~/notes --name notes -qmd update -qmd embed -``` - -Health and diagnostics: - -```bash -qmd doctor -qmd status -qmd pull -``` - -`qmd doctor` checks config, model cache, device/GPU setup, vector fingerprints, -and common environment overrides. If a model-backed command fails, run it before -changing configuration. - -## MCP setup - -See `references/mcp-setup.md` for Claude Code, Claude Desktop, OpenClaw, and HTTP -server configuration. - -## Pitfalls - -- **Do not stop at snippets.** Fetch documents before making claims. -- **Do not slice files with `sed`/`head`/`tail`.** Use the `path:from:count` - suffix (e.g. `qmd get "#abc123:120:40"`) or `--from`/`-l`. Output is already - line-numbered; piping breaks docid resolution, the header, and virtual paths. -- **Do not lean on query expansion.** Write `intent:`/`lex:`/`vec:`/`hyde:` - yourself. A bare `qmd query "user sentence"` discards the context only you - have. You expand the query; the model just ranks. -- **Do not overuse semantic search.** If you know exact titles or terms, BM25 is - faster and often better. -- **Do not mutate indexes casually.** `qmd collection add`, `qmd update`, and - `qmd embed` change local state and can be expensive. -- **Model-backed commands can be environment-sensitive.** If `qmd query`, - `qmd vsearch`, or reranking fails because local models/GPU are unavailable, - use `qmd search` and stronger lexical/structured terms. -- **Ambiguous user wording needs intent.** Add `intent:` rather than hoping query - expansion guesses the right domain. -- **Collection names matter.** Search `concepts` for synthesized wiki pages, - `sources` for transcripts/raw source pages, and docs collections for code or - project documentation. diff --git a/.agents/skills/qmd/references/mcp-setup.md b/.agents/skills/qmd/references/mcp-setup.md deleted file mode 100644 index 5d32a62..0000000 --- a/.agents/skills/qmd/references/mcp-setup.md +++ /dev/null @@ -1,102 +0,0 @@ -# QMD MCP Server Setup - -## Install - -```bash -npm install -g @tobilu/qmd -qmd collection add ~/path/to/markdown --name myknowledge -qmd embed -``` - -## Configure MCP Client - -**Claude Code** (`~/.claude/settings.json`): -```json -{ - "mcpServers": { - "qmd": { "command": "qmd", "args": ["mcp"] } - } -} -``` - -**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`): -```json -{ - "mcpServers": { - "qmd": { "command": "qmd", "args": ["mcp"] } - } -} -``` - -**OpenClaw** (`~/.openclaw/openclaw.json`): -```json -{ - "mcp": { - "servers": { - "qmd": { "command": "qmd", "args": ["mcp"] } - } - } -} -``` - -## HTTP Mode - -```bash -qmd mcp --http # Port 8181 -qmd mcp --http --daemon # Background -qmd mcp stop # Stop daemon -``` - -## Tools - -### structured_search - -Search with pre-expanded queries. - -```json -{ - "searches": [ - { "type": "lex", "query": "keyword phrases" }, - { "type": "vec", "query": "natural language question" }, - { "type": "hyde", "query": "hypothetical answer passage..." } - ], - "limit": 10, - "collection": "optional", - "minScore": 0.0 -} -``` - -| Type | Method | Input | -|------|--------|-------| -| `lex` | BM25 | Keywords (2-5 terms) | -| `vec` | Vector | Question | -| `hyde` | Vector | Answer passage (50-100 words) | - -### get - -Retrieve document by path or `#docid`. - -| Param | Type | Description | -|-------|------|-------------| -| `path` | string | File path or `#docid` | -| `full` | bool? | Return full content | -| `lineNumbers` | bool? | Add line numbers | - -### multi_get - -Retrieve multiple documents. - -| Param | Type | Description | -|-------|------|-------------| -| `pattern` | string | Glob or comma-separated list | -| `maxBytes` | number? | Skip large files (default 10KB) | - -### status - -Index health and collections. No params. - -## Troubleshooting - -- **Not starting**: `which qmd`, `qmd mcp` manually -- **No results**: `qmd collection list`, `qmd embed` -- **Slow first search**: Normal, models loading (~3GB) diff --git a/.agents/skills/routing/SKILL.md b/.agents/skills/routing/SKILL.md deleted file mode 100644 index e46a726..0000000 --- a/.agents/skills/routing/SKILL.md +++ /dev/null @@ -1,330 +0,0 @@ ---- -name: routing -description: Use whenever work adds, changes, moves, debugs, or reviews application routes, route or page directories, URL paths, path or search params, navigation, redirects, layouts, outlets, guards, loaders, route-tree composition, router context or history, deep-link mapping, not-found behavior, or routed Storybook/test harnesses. Also use when deciding where a route or page belongs, even if the requested change is described only as a file move or a new screen. ---- - -# Routing - -Model application navigation as one explicit, typed tree. Keep URL contracts, -route declarations, page components, and tree composition separate enough that -each has one owner and the import graph stays acyclic. - -## Inspect The Existing Router First - -Before changing routes: - -1. Find the router entrypoint, root route, top-level branches, guards, and - not-found handling. -2. Trace the concrete parent chain of the affected page. -3. Inspect how the project validates params/search, loads route data, lazy-loads - components, and constructs links. -4. Check platform constraints such as browser, hash, or memory history and any - deep-link adapter. -5. Preserve a coherent local router convention unless this task explicitly - corrects or migrates it. - -Do not infer routing from directory names alone. The router definition is the -runtime source of truth. - -## Keep The Route Tree At The Source Root - -Prefer one `routes/` directory at the application source root: - -```text -src/ -├── api/ -├── routes/ -│ ├── __root.tsx -│ ├── router.tsx -│ ├── guards.ts -│ ├── app/ -│ ├── auth/ -│ └── local-auth/ -└── ui/ -``` - -Do not nest this root under an application branch and produce shapes such as -`app/routes/app`. `routes/app`, `routes/auth`, and `routes/local-auth` are clear -siblings in one navigation tree. - -These directories represent routing scopes, not necessarily literal URL -segments. For example, `routes/app` may be a pathless authenticated layout with -an internal id, while `routes/auth` may own the real `/auth` segment. Choose -branch names for the boundary they own; define the URL explicitly in the route. - -If the framework mandates filesystem routing, adapt the filenames it requires -while preserving the ownership and composition rules in this skill. - -## Treat URLs As Resource Contracts - -- Use stable, resourceful paths: `/projects` and `/projects/$projectId`. -- Put resource identity in path params. -- Put filters, sorting, tabs, pagination, and view options in search params. -- Keep transient component state outside the URL only when it has no navigation, - sharing, refresh, or history meaning. -- Treat import/export formats and persisted files as data contracts, not routes. -- Define redirects deliberately; do not use them to conceal an incoherent tree. - -A page directory mirrors ownership in the route tree, but it does not create a -route by filesystem magic unless the selected router explicitly works that way. - -## Use Three Deliberate Directory Shapes - -### Layout or guard branch - -Use a route-owning branch when descendants share a layout, guard, or URL segment: - -```text -routes/app/pages/Projects/ -├── index.tsx # ProjectsLayout with Outlet -├── route.tsx # /projects -├── route.tree.ts # List route + Item subtree -├── components/ # shared by project routes -└── pages/ - ├── List/ - │ ├── index.tsx # project list page - │ └── route.tsx # /projects - └── Item/ - ├── index.tsx # ProjectLayout with Outlet - ├── route.tsx # /projects/$projectId - ├── route.tree.ts # index redirect + item subpages - ├── components/ # shared by one project's subpages - └── pages/ - ├── Overview/ - │ ├── index.tsx - │ └── route.tsx # /projects/$projectId/overview - └── Workspace/ - ├── index.tsx - └── route.tsx # /projects/$projectId/workspace -``` - -`Projects/index.tsx` is a layout because `List` and `Item` are its route -children. `Item/index.tsx` repeats the same role one level deeper because -`Overview` and `Workspace` are its children. The pattern is recursive: any leaf -may become a layout boundary when it gains real child pages. - -Use contextual page names inside an established owner. `Projects/pages/Item` -means the selected project route; keep `ProjectItem` for a list row/card -component and `projectId` for the route parameter. Name rendered components by -their UI role, such as `ProjectsLayout`, `ProjectListPage`, `ProjectLayout`, and -`ProjectOverviewPage`. - -### Page with direct subpages - -When a page is not a resource collection and has no selected-id level, omit the -artificial `List` and `Item` layers: - -```text -routes/app/pages/Project/ -├── index.tsx # ProjectLayout with Outlet -├── route.tsx # /project + explicit index route -├── route.tree.ts -└── pages/ - ├── Overview/ - │ ├── index.tsx - │ └── route.tsx # /project/overview - ├── Workspace/ - │ ├── index.tsx - │ └── route.tsx # /project/workspace - └── Settings/ - ├── index.tsx - └── route.tsx # /project/settings -``` - -Define what happens at `/project` explicitly. It may redirect to `Overview`, -`Workspace`, `Settings`, or another existing child, but there is no universal -default: choose the destination at this boundary from the product's business -logic. Never infer it from child order or silently treat the first registered -route as the default. - -Keep the redirect local and visible: - -- use an owned index route declaration when the decision is available from - router context, a guard, a loader, or static product policy; -- render a named index-forwarder component when the decision requires - render-time state or hooks; -- let `route.tree.ts` register that index route with the subpages, but keep the - redirect decision out of tree-composition code; -- keep the ordinary layout component focused on shared UI and its `Outlet`. - -### Terminal page without subpages - -Treat `Overview`, `Workspace`, and `Settings` in the preceding example as leaf -pages: each is a terminal route with no child routes, `Outlet`, or -`route.tree.ts`. A leaf page normally owns: - -- `index.tsx`: the page component only; -- `route.tsx`: the route declaration, schema, guard/loader when leaf-specific, - and lazy component binding. - -Private page components, hooks, and other implementation go inward under the -same page boundary according to **file-structure**. When a leaf gains real -subpages, promote it to the layout-branch shape and apply the same structure -recursively. - -For example, keep components used only by `Overview` inside that page: - -```text -Overview/ -├── index.tsx -├── route.tsx -└── components/ - ├── ProjectSummary.tsx - └── ActivityPanel/ - ├── index.tsx - └── components/ - └── ActivityRow.tsx -``` - -`routing` owns the `Overview` page boundary. **file-structure** owns everything -inside it: place each component, hook, schema, or other implementation at the -narrowest boundary containing all of its consumers, and repeat that rule -recursively. - -### Structural group without a route - -When sibling pages need grouping but share no URL segment, guard, or layout, do -not invent a wrapper route: - -```text -routes/auth/pages/PasswordRecovery/ -├── route.tree.ts -└── pages/ - ├── RequestReset/ - │ ├── index.tsx - │ └── route.tsx - └── ResetPassword/ - ├── index.tsx - └── route.tsx -``` - -Its `route.tree.ts` exports the sibling route collection for the nearest real -parent to compose. A directory is not entitled to a route merely because it -exists. - -## Separate Declaration, Rendering, And Composition - -Give each routing file one job: - -- `route.tsx` declares only routes owned by that boundary. It imports its - concrete parent and lazy-loads the page/layout component. It never imports or - assembles descendant routes. -- `index.tsx` renders only the page or layout. A layout renders an `Outlet`; a - leaf renders its screen. It does not assemble the route tree. -- `route.tree.ts` imports sibling/descendant route objects and composes them with - their parent. It contains no screen UI, schemas, data lookup, or route-specific - branching. -- `router.tsx` attaches top-level branch trees to the root and owns global router - configuration. - -This keeps the structural import graph flowing child declaration -> concrete -parent declaration, then composition -> children. Parent declarations never -import their children, so page components can safely import their own route -object for typed hooks. - -Do not use barrels to aggregate routes. Import the exact `route` or `route.tree` -module so parentage and ownership remain visible. - -## Assemble Recursively - -Compose each subtree at the narrowest boundary that owns all of its children, -then pass one tree or a small route collection upward: - -```text -router.tsx -└── app/route.tree.ts - └── Projects/route.tree.ts - ├── List/route.tsx - └── Item/route.tree.ts - ├── route.tsx # optional item index redirect - ├── Overview/route.tsx - └── Workspace/route.tsx -``` - -Do not centralize every leaf in `router.tsx`. Do not declare child paths in a -parent component. Do not widen typed route collections to a generic route type -merely to make composition compile; preserve inferred route identities. - -Routing-specific placement is authoritative over **file-structure** for route -branches, pages, declarations, and tree assembly. Apply **file-structure** -recursively inside the selected page or layout boundary. - -## Put Behavior At The Narrowest Route Boundary - -- Put a guard on the nearest ancestor whose entire subtree shares the rule. -- Put a loader on the route that owns the navigation dependency. Reuse the - project's API query definitions and cache instead of building a second fetch - or cache policy inside the loader. -- Put a search schema beside the leaf that reads it. -- When sibling leaves consume the same search contract, put it in a small module - at their nearest shared boundary; do not force it into a parent declaration - that would create a parent-child import cycle. -- Keep global router context and history selection at the router root. -- Treat platform history choices as adapters: browser history is not universally - correct, and hash history is not universally correct. - -Reuse the project's established error, auth, preload, cache, and redirect flows. -Do not invent a parallel routing policy inside one page. - -## Read Route State Through The Route Contract - -Prefer the route object's typed params/search hooks when the router supports -them. Avoid handwritten route ids, string casts, and generic APIs that discard -which route owns a value. - -Pathless layouts can contribute internal ids without contributing URL segments. -Therefore an internal route id may differ from the visible path. Code and routed -test harnesses must preserve that distinction. - -Use a loose/non-strict route-state read only for genuinely route-agnostic shared -behavior, and accept the reduced per-route typing intentionally. - -## Navigate Semantically - -- Use the router's `Link` for user-initiated navigation so accessibility, - history, preloading, and modifier-click behavior remain intact. -- Use programmatic navigation for event outcomes and side-effect redirects such - as successful submission, authentication changes, or guard resolution. -- Construct destinations with typed `to`, `params`, and `search` values rather - than concatenating URL strings. -- Keep sidebar/header navigation separate from route registration. A valid route - does not automatically belong in primary navigation. -- Map platform deep links into the same route contracts. Keep transport and - operating-system event handling in the platform integration boundary instead - of creating a second navigation tree. - -## Preserve Real Routes In Tests And Stories - -Pages that read route-owned params or search values need an active match with -the same effective route id and parent chain. A flat memory router around the -component is insufficient when pathless ancestors contribute ids. - -Build the smallest parallel route chain that preserves the production ids, then -provide page chrome as a shell. Do not weaken production route typing or add -fallback params merely to make a story render. - -## Verify The Contract - -After a routing change: - -1. Search for stale paths, route ids, imports, links, redirects, deep-link - mappings, and navigation entries. -2. Typecheck the registered route tree without generic widening or casts. -3. Exercise direct entry, refresh, back/forward history, links, and - programmatic redirects. -4. Verify valid and invalid path/search params, guards, loaders, and not-found - behavior. -5. Run affected routed tests and stories, then the repository's required - lint/tests/build. - -Read [references/tanstack-router.md](references/tanstack-router.md) when the -project uses TanStack Router, when pathless layout ids are involved, or when a -routed Storybook/test harness must reproduce production route identity. - -## Skill Boundaries - -- Route loader queries and cache ownership → **api-integration**. -- Page and layout component implementation → **components**. -- Non-routing placement inside a page boundary → **file-structure**. -- Native deep-link transport and event handling → **native-integration** or - the project's platform integration skill. diff --git a/.agents/skills/routing/agents/openai.yaml b/.agents/skills/routing/agents/openai.yaml deleted file mode 100644 index 706ce38..0000000 --- a/.agents/skills/routing/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Application Routing" - short_description: "Own route contracts, page trees, and navigation" - default_prompt: "Use $routing to design or change application routes, page hierarchy, guards, params, navigation, or route-tree composition." diff --git a/.agents/skills/routing/references/tanstack-router.md b/.agents/skills/routing/references/tanstack-router.md deleted file mode 100644 index aac46e1..0000000 --- a/.agents/skills/routing/references/tanstack-router.md +++ /dev/null @@ -1,343 +0,0 @@ -# TanStack Router Patterns - -Use these examples only when the project uses TanStack Router. The directory and -ownership rules remain applicable with other routers, but their APIs will differ. - -## Contents - -- Root and top-level branches -- Pathless guarded branches -- Recursive route layouts -- Direct subpage layouts and landing redirects -- Typed leaf state -- Recursive composition -- Shared search contracts -- Routed Storybook and tests - -## Root And Top-Level Branches - -Keep the root route, router creation, and branch assembly at `src/routes/`: - -```tsx -// routes/__root.tsx -export const rootRoute = createRootRouteWithContext<RouterContext>()({ - component: RootLayout, -}) - -// routes/router.tsx -const routeTree = rootRoute.addChildren([appTree, authTree, catchAllRoute]) - -export const router = createRouter({ - routeTree, - context: { queryClient }, - defaultPreload: 'intent', -}) - -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } -} -``` - -The exact router options are project decisions. For example, a desktop -application loaded from a file protocol may need hash history, while a hosted -web application normally uses browser history. Copy the boundary, not another -project's platform choice. - -## Pathless Guarded Branch - -An authenticated application branch may be pathless: - -```tsx -// routes/app/route.tsx -export const appLayoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: 'app', - beforeLoad: requireAuthenticated, - component: lazyRouteComponent(() => import('./index')), -}) - -export const appIndexRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/', - component: lazyRouteComponent(() => import('./pages/Home')), -}) -``` - -```tsx -// routes/app/index.tsx -export default function AppLayout() { - return ( - <AppShell> - <Outlet /> - </AppShell> - ) -} -``` - -The `app` id participates in internal route identity but not in the visible URL. -Do not assume the route id of `/projects/$projectId` is equal to that visible -path when it descends from the pathless branch. - -## Recursive Route Layouts - -Give `/projects` a layout route when both its list and selected item are child -routes: - -```tsx -// routes/app/pages/Projects/route.tsx -export const projectsRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/projects', - component: lazyRouteComponent(() => import('./index')), -}) - -// routes/app/pages/Projects/pages/List/route.tsx -export const Route = createRoute({ - getParentRoute: () => projectsRoute, - path: '/', - component: lazyRouteComponent(() => import('./index')), -}) - -// routes/app/pages/Projects/pages/Item/route.tsx -export const projectItemRoute = createRoute({ - getParentRoute: () => projectsRoute, - path: '$projectId', - component: lazyRouteComponent(() => import('./index')), -}) - -export const projectItemIndexRoute = createRoute({ - getParentRoute: () => projectItemRoute, - path: '/', - beforeLoad: ({ params }) => { - throw redirect({ - to: '/projects/$projectId/overview', - params, - }) - }, -}) -``` - -```tsx -// routes/app/pages/Projects/index.tsx -export default function ProjectsLayout() { - return <Outlet /> -} - -// routes/app/pages/Projects/pages/Item/index.tsx -import { projectItemRoute } from './route' - -export default function ProjectLayout() { - const { projectId } = projectItemRoute.useParams() - - return ( - <ProjectProvider projectId={projectId}> - <Outlet /> - </ProjectProvider> - ) -} -``` - -`Projects` owns the `/projects` segment and shared project-area layout. `List` -is its index child. `Item` owns `$projectId` and becomes another layout because -it has `Overview` and `Workspace` children. If `Item` has no child pages, omit -its `route.tree.ts` and `pages/`; its `index.tsx` is then the leaf screen. - -An item index may render `Overview` at path `/`, or redirect -`/projects/$projectId` to an explicit `overview` child. Choose one canonical URL -and define that index behavior in the item route boundary. - -## Direct Subpage Layouts And Landing Redirects - -Omit collection/item layers when the route is an ordinary page with direct -subpages: - -```tsx -// routes/app/pages/Project/route.tsx -export const projectRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/project', - component: lazyRouteComponent(() => import('./index')), -}) - -export const projectIndexRoute = createRoute({ - getParentRoute: () => projectRoute, - path: '/', - component: lazyRouteComponent( - () => import('./index'), - 'ProjectIndexForwarder', - ), -}) -``` - -```tsx -// routes/app/pages/Project/index.tsx -export default function ProjectLayout() { - return <Outlet /> -} - -export function ProjectIndexForwarder() { - const canOpenWorkspace = useCanOpenWorkspace() - - return ( - <Navigate - replace - to={canOpenWorkspace ? '/project/workspace' : '/project/overview'} - /> - ) -} -``` - -```tsx -// routes/app/pages/Project/route.tree.ts -import { Route as projectOverviewRoute } from './pages/Overview/route' -import { Route as projectSettingsRoute } from './pages/Settings/route' -import { Route as projectWorkspaceRoute } from './pages/Workspace/route' -import { projectIndexRoute, projectRoute } from './route' - -export const projectTree = projectRoute.addChildren([ - projectIndexRoute, - projectOverviewRoute, - projectWorkspaceRoute, - projectSettingsRoute, -]) -``` - -The index route is mandatory when the layout route itself has no screen. The -destination is not prescribed: select an existing child from local business -rules. Use an inline redirect or route lifecycle function when the choice is -static or available before render. Use a named index-forwarder component when -the choice requires React state or hooks. In both cases, keep the redirect -explicit and keep `route.tree.ts` limited to composition. - -## Typed Leaf State - -Keep each leaf route object at module level and let its component import it: - -```tsx -// routes/app/pages/Projects/pages/Item/pages/Overview/route.tsx -const searchSchema = z.object({ - tab: z.enum(['summary', 'history']).optional().catch('summary'), -}) - -export const Route = createRoute({ - getParentRoute: () => projectItemRoute, - path: 'overview', - validateSearch: searchSchema, - component: lazyRouteComponent(() => import('./index')), -}) -``` - -```tsx -// routes/app/pages/Projects/pages/Item/pages/Overview/index.tsx -import { Route } from './route' - -export default function ProjectOverviewPage() { - const { projectId } = Route.useParams() - const { tab } = Route.useSearch() - - return <ProjectOverview projectId={projectId} tab={tab} /> -} -``` - -Do not replace this with `getRouteApi('<guessed-id>')`, -`useParams({ from: '<guessed-id>' })`, or a cast. A pathless ancestor can make a -guessed id wrong at runtime even when weakened types let it compile. - -`useSearch({ strict: false })` is acceptable only in route-agnostic shared code -such as a generic URL-filter hook. It trades route-specific guarantees for -portability. - -## Recursive Composition - -A route declaration imports its concrete parent. A tree module imports children: - -```tsx -// routes/app/pages/Projects/pages/Item/route.tree.ts -import { Route as overviewRoute } from './pages/Overview/route' -import { Route as workspaceRoute } from './pages/Workspace/route' -import { projectItemIndexRoute, projectItemRoute } from './route' - -export const projectItemTree = projectItemRoute.addChildren([ - projectItemIndexRoute, - overviewRoute, - workspaceRoute, -]) -``` - -```tsx -// routes/app/pages/Projects/route.tree.ts -import { projectItemTree } from './pages/Item/route.tree' -import { Route as projectListRoute } from './pages/List/route' -import { projectsRoute } from './route' - -export const projectsTree = projectsRoute.addChildren([ - projectListRoute, - projectItemTree, -]) -``` - -For an organizational group with no route of its own, export the leaves as a -collection and spread them into the real parent: - -```tsx -// routes/auth/pages/PasswordRecovery/route.tree.ts -export const passwordRecoveryRoutes = [ - requestResetRoute, - resetPasswordRoute, -] - -// routes/auth/route.tree.ts -export const authTree = authRoute.addChildren([ - signInRoute, - ...passwordRecoveryRoutes, -]) -``` - -Do not annotate these values as `AnyRoute[]`. Generic widening erases the route -tree information that powers typed ids, params, search, links, and navigation. -Let inference preserve the concrete tuple/array types. - -Avoid generic factories such as -`createProjectRoutes<TParentRoute extends AnyRoute>(parent)`. They obscure the -real parent and make route identity easier to widen accidentally. Import the -concrete parent route instead. - -## Shared Search Contracts - -A search schema belongs to the route that reads it. If two sibling routes share -the same external query contract, place it in a sibling module: - -```text -routes/auth/ -├── search-schema.ts -└── pages/ - ├── AcceptInvite/route.tsx - └── SignUp/route.tsx -``` - -Both leaves import the schema. The parent `route.tsx` does not import from its -children, so the route graph stays acyclic. - -Validate search values from external URLs and deep links as untrusted input. -Choose strict, coercing, defaulting, or catch behavior according to the actual -contract; do not silently normalize values merely because another project did. - -## Routed Storybook And Tests - -`Route.useParams()` and `Route.useSearch()` resolve against the active match's -effective id. When the production page is below `id: 'app'`, a flat test route -with only the visible path does not provide the same match. - -Create a minimal parallel chain: - -```text -test root -└── pathless layout (id: app) - └── leaf (same visible full path) -``` - -The route objects need not be identical; the effective route-id chain must -match. Render shared application chrome around the routed outlet as test/story -shell content. This preserves production page code and its typed hooks. diff --git a/.agents/skills/setup-workflow-environment/SKILL.md b/.agents/skills/setup-workflow-environment/SKILL.md deleted file mode 100644 index fc6d370..0000000 --- a/.agents/skills/setup-workflow-environment/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: setup-workflow-environment -description: Install, update, repair, or explain the shared project workflow environment in a clean or initialized knowledge repository or leaf source repository. Use when a maintainer asks to bootstrap wfctl, connect a repository to project knowledge, update workflow rules or skills, preserve existing AGENTS.md or CLAUDE.md instructions, or diagnose a broken workflow installation. ---- - -# Setup Workflow Environment - -Install through deterministic `wfctl` operations while preserving all unowned consumer content. - -## Command ownership - -Run `wfctl init`, `wfctl upgrade`, and `wfctl check` yourself when terminal -access permits. Ask the maintainer for repository kind, paths, skill targets, -scope, and conflict decisions, not routine command execution. Provide a manual -command only when bootstrapping without `wfctl`, missing authority, or tool -access prevents execution; state that blocker explicitly. - -Treat `wfctl init knowledge`, `wfctl init leaf`, and repository-local `wfctl -upgrade` as the normal optional maintainer-facing CLI entry points, and all may -be delegated to this skill. `--target`, `check`, `knowledge`, `work`, QMD, and -Graphify operations belong to the agent unless the maintainer explicitly -requests manual or automation-oriented instructions. - -## Procedure - -1. Confirm that Bun and `wfctl` are available. If `wfctl` is missing, stop and - tell the maintainer how to install or link the canonical Bun package. -2. Confirm `qmd --version` reports at least `2.5.3`. If it is missing or old, - ask for installation authority and run - `bun install -g @tobilu/qmd@2.5.3`. QMD is the supported knowledge - retrieval engine; do not substitute a custom indexer. -3. Before leaf initialization, require both the `graphify` CLI and the official - native Graphify skill in the current session. Do not require either for a - knowledge repository that is not inspecting source. If the CLI is absent, - ask for user-level installation authority and run `uv tool install - graphifyy`. If the native skill is absent, ask for authority and run - `graphify install --platform <agent>` once for every selected agent - platform. Then tell the maintainer to restart the agent and stop: an on-disk - skill is not active in the current session. After restart, verify both - requirements and repeat `wfctl init leaf`. Knowledge retrieval and raw - intake use QMD, not Graphify. -4. Identify whether the target is a `knowledge` or `leaf` repository. - A knowledge target may be a new directory without Git: ask the maintainer - for authority to initialize it and pass `--init-git`. In an interactive - terminal, `wfctl` asks this itself. A leaf must already be an existing Git - repository; never use `--init-git` to turn an arbitrary leaf path into one. -5. For a leaf repository, obtain the local knowledge-repository path. Do not guess it. -6. Inspect existing `AGENTS.md`, `CLAUDE.md`, `.claude/rules`, `.agents/skills`, and `.claude/skills`, including symlink targets. -7. Run `wfctl init <knowledge|leaf> --target <path>` with `--knowledge <path>` - for a leaf. Use the default project skill scope unless the maintainer chooses - user scope or no skill installation. The dependency preflight must pass - before the command writes files. A successful leaf initialization must also - run `graphify update .` from the exact target checkout; do not accept a - checkout whose local graph was never built. Preserve the existing root - `.gitignore` while ensuring it excludes `graphify-out/`. Successful leaf - init must also register durable repository identity in knowledge and add - this exact worktree to ignored local state. It must not change that - repository's active reconstruction selection. -8. Review the preview before confirming. For each conflict, preserve the - existing content, accept the offered per-file backup and replacement, or - stop. Never invent a blanket overwrite. -9. Let `wfctl` update managed instruction and guide blocks. If their markers - are malformed or duplicated, stop and repair them with the maintainer rather - than replacing the whole file. Use `wfctl init <kind> - --print-instructions agents|guide` to obtain the exact managed text. -10. Confirm that `PROJECT_WORKFLOW.md` preserves pre-existing text outside the - managed block. -11. Confirm the installed skills include the version-matched official `qmd` - skill from `qmd skills path qmd` for every selected agent. New skills are - not active in the current session automatically; tell the maintainer to - restart the agent session before knowledge-dependent work. - Confirm `explore-project-knowledge` is installed for both profiles so - project discovery works identically from knowledge and leaf sessions. - Confirm `shape-project-direction`, `specify-project-change`, - `split-project-change`, and `implement-work-item` are installed for both - profiles. For a knowledge profile, also confirm - `research-project-context` is installed. -12. For a knowledge profile, confirm that `.qmd/index.yml` defines separate - `knowledge`, `changes`, `intake`, `reconstruction`, and `raw` collections. - Confirm that `reconstruction/active` and `reconstruction/archive` exist and - `reconstruct-project-knowledge` is installed. `wfctl init` - builds `.workflow/current/knowledge-graph.json` plus - `.workflow/current/claim-ledger.json` and runs `qmd update`, so explicit - knowledge navigation, claim-lineage audit, and BM25 retrieval must be - ready immediately. Treat - `qmd-models` and `qmd-embeddings` warnings as optional semantic setup, not - as lexical-index success. Ask before `qmd pull` or `qmd embed`; the current - model set is roughly 2 GB. -13. Run `wfctl check --target <path>` and report every failure and warning. For - a leaf, confirm `graphify-graph` passes and refers to this checkout, not a - sibling repository or another worktree, and confirm `graphify-ignore` - passes. Confirm `repository-connection` identifies this exact known - checkout and reports whether it is selected as the default, awaiting - selection, or registered as an alternative for reconstruction. - For a knowledge repository, report registered repositories, known - worktrees, and explicit default reconstruction selections. Deferred - selection is healthy during setup and must not be reported as a warning. - Do not select during - initialization; `reconstruct-project-knowledge` owns contextual selection - when reconstruction is actually requested. -14. For a knowledge profile, run `wfctl knowledge validate` and - `wfctl knowledge build`; do not create a raw intake case unless intake - processing was requested. -15. Point the maintainer to `PROJECT_WORKFLOW.md`; it defines their review - responsibilities for both profiles. - -Use `wfctl upgrade` for an existing installation. A workflow 0.3 knowledge -repository requires a content migration after the generated assets upgrade: - -1. inventory every existing curated concept without changing its meaning; -2. route stakeholder current behavior to `curate-product-knowledge`; -3. route technical realization to `curate-engineering-knowledge`; -4. split any mixed concept and connect the two views with explicit links; -5. preserve decision lineage, provenance, delivery state, and uncertainty; -6. invoke `verify-knowledge-quality` and record fresh quality plus normal - verification receipts for the final content hash; -7. run `wfctl knowledge validate` and `wfctl knowledge build`. - -Never infer a view, audience, or product meaning merely to silence an upgrade -error. Leave the concept draft and ask the maintainer when authority is -missing. Read [the installation contract](references/install-contract.md) when -handling unusual files or symlinks. - -## Safety - -- Preserve text outside `wfctl` markers. -- Never replace an existing file or directory with a symlink. -- Treat local edits to owned generated files as conflicts. -- Do not claim Codex consumes `.claude/rules`; Codex receives the routing contract through `AGENTS.md`. -- Let the pinned `skills` CLI install both workflow skills and QMD's - version-matched native skill for the selected agent targets and scope. Do not - hand-copy skills around it. diff --git a/.agents/skills/setup-workflow-environment/agents/openai.yaml b/.agents/skills/setup-workflow-environment/agents/openai.yaml deleted file mode 100644 index e922f25..0000000 --- a/.agents/skills/setup-workflow-environment/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Setup Workflow Environment" - short_description: "Install or repair wfctl environments" - default_prompt: "Use $setup-workflow-environment to install or repair this repository workflow safely." diff --git a/.agents/skills/setup-workflow-environment/references/install-contract.md b/.agents/skills/setup-workflow-environment/references/install-contract.md deleted file mode 100644 index 9a087bb..0000000 --- a/.agents/skills/setup-workflow-environment/references/install-contract.md +++ /dev/null @@ -1,140 +0,0 @@ -# Installation contract - -## Instruction files - -- Create `AGENTS.md` when absent. -- Update only the block between `<!-- wfctl:begin -->` and `<!-- wfctl:end -->`. -- When `CLAUDE.md` is absent, link it to `AGENTS.md`. -- When `CLAUDE.md` is a regular file, preserve it and maintain a separate marked block. -- Treat malformed, duplicated, broken, or externally targeted managed symlinks as conflicts. - -## Skills - -- Delegate skill placement to the pinned `skills` CLI. -- Use the installer's copy mode for every selected agent. Do not create - cross-agent symlinks between `.agents/skills` and `.claude/skills`. -- Default to project scope; use user scope or no installation only when the - maintainer chooses it. -- Install `setup-workflow-environment`, `analyze-with-graphify`, and the - version-matched official `qmd` skill for both profiles. -- Install `operate-project-knowledge` only for the knowledge profile as the - default router for explanation, history, audit, navigation, contradiction, - and triage requests. -- Install `explore-project-knowledge` for both profiles. It gives knowledge and - leaf sessions the same read-only progressive product-discovery path through - the configured knowledge repository without requiring user knowledge of - Areas, capabilities, or files. -- Install `process-raw-intake` only for the knowledge profile. -- Install `reconstruct-project-knowledge` only for the knowledge profile. -- Install `research-project-context` only for the knowledge profile. -- Install `shape-project-direction` for both profiles so an unbounded - significant initiative can resolve its Wayfinder map inside the same central - bundle before implementation starts. -- Install `specify-project-change`, `split-project-change`, and - `implement-work-item` for both profiles so specification synthesis, - dependency-aware issue creation, and exact-worktree execution are explicit - reusable modes rather than hidden conventions. -- Install `curate-project-knowledge` for both profiles because a leaf agent - must promote durable truth before closing significant work. -- Install `curate-product-knowledge`, `curate-engineering-knowledge`, and - `verify-knowledge-quality` for both profiles. The orchestrator depends on - them to keep stakeholder and technical views separate and to bind independent - authority/truth and reader-communication review to the current content hash. -- Install alignment, work management, and verification skills for both - profiles. Knowledge needs them for project-only and multi-repository bundles; - a project-only bundle has no implementation checkout. -- Select Codex, Claude, or both explicitly. -- Let the pinned installer update an already owned selected skill in - non-interactive mode. Remove only obsolete project-scope workflow skills - whose lock entry still identifies this package; never remove an unowned - skill by name. - -The workflow skill `analyze-with-graphify` is a routing and policy gate, not a -copy of Graphify's native skill. Require the `graphify` CLI and the official -native `graphify` skill supplied by that tool whenever source code must be -analyzed. Verify native-skill availability against the current session -catalog; an on-disk file alone does not prove the running agent loaded it. -When the CLI is absent, ask for user-level installation authority and run -`uv tool install graphifyy`. Install the native skill with `graphify install ---platform <agent>` for every selected platform, then restart the session. -`graphify install --help` is the authority for currently supported platform -names. Do not report setup success until the restarted session exposes the -native skill. -Do not require Graphify for Markdown intake or OKF curation that does not inspect -source code. - -Run `graphify update .` from the exact leaf checkout after applying an -initialization or upgrade. This graph is worktree-local evidence infrastructure: -do not reuse a sibling checkout's `graphify-out`, and do not report setup -success when the update command fails or the resulting graph has no nodes. -Maintain a `# wfctl:begin` / `# wfctl:end` block in the root `.gitignore` when -no existing exact `graphify-out/` rule already covers the artifact. Preserve -every pre-existing ignore rule. - -The QMD skill is not maintained as a workflow copy. Resolve its source with -`qmd skills path qmd`, then let the same pinned `skills` CLI copy it to the -selected Codex and Claude targets. This preserves agent-target and scope -semantics while keeping the skill matched to the installed QMD version. - -## Knowledge retrieval - -- Require QMD `>=2.5.3` for both profiles because leaf alignment reads the - linked knowledge repository and skill discovery depends on - `qmd skills path qmd`. -- Install the supported baseline through Bun when authorized: - `bun install -g @tobilu/qmd@2.5.3`. -- For a knowledge profile, let `wfctl` own `.qmd/index.yml` and - `.qmd/.gitignore`. -- Keep QMD's database and model cache out of Git. The index is disposable and - rebuildable from repository content. -- Include only `knowledge` in unscoped searches. Require explicit collection - selection for `changes`, `intake`, `reconstruction`, and `raw`. -- Run QMD from the knowledge root so it uses the project-local index. -- Run `qmd update` during knowledge initialization and upgrade so lexical BM25 - retrieval is ready before success is reported. -- Diagnose `qmd status` and `qmd doctor` separately. A working lexical index is - required. Missing models or stale embeddings are warnings until semantic or - hybrid retrieval is needed. - -## Knowledge and claim relationship graphs - -- Compile authored Markdown links and workflow relation metadata with - `wfctl knowledge build`; do not infer semantic relationships. -- Store the generated knowledge artifact at - `.workflow/current/knowledge-graph.json` and the explicit intake/ - reconstruction claim ledger at `.workflow/current/claim-ledger.json`; both - are already ignored. -- Build both during a valid knowledge initialization or upgrade. -- Make `wfctl check` fail when either artifact is missing, invalid, or stale. -- Keep QMD responsible for retrieval and Graphify responsible for source-code - structure. The compiled graphs only represent authored knowledge links and - explicit claim lineage. - -## Rules - -- Install readable copies under `.workflow/rules`. -- Mirror namespaced rule files under `.claude/rules`. -- Route Codex to `.workflow/rules` through the managed `AGENTS.md` block. - -## Maintainer guide - -- Install `PROJECT_WORKFLOW.md` as a managed, visible root document for both - profiles, using a managed block that preserves text outside the markers. -- Render profile-specific content and the configured knowledge path. -- Treat malformed or duplicated managed markers as a conflict. -- Stop for maintainer-controlled repair when markers cannot be updated safely. - Obtain the exact block with `wfctl init <kind> --print-instructions guide`. - -## Ownership - -`.workflow/state.json` records the hash of every installed owned file. Update an owned file only when the on-disk hash still matches the prior installed hash. Equal content is safe to adopt. Any other pre-existing content is a conflict. -When a later release removes an owned file, delete it only if its hash still -matches the recorded installed hash. Back it up first. A locally modified -obsolete file remains an explicit conflict. - -`.workflow/repositories.json` is a dynamic, Git-tracked project source -registry, not a generated asset. It stores repository identity without local -paths. `.workflow/current/repositories.json` is ignored and stores any number -of known local worktrees per repository plus one explicit active selection -used only by default reconstruction. Leaf initialization adds a known worktree -but never changes an existing selection. diff --git a/.agents/skills/shape-project-direction/SKILL.md b/.agents/skills/shape-project-direction/SKILL.md deleted file mode 100644 index 0280caa..0000000 --- a/.agents/skills/shape-project-direction/SKILL.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -name: shape-project-direction -description: Run deliberate Wayfinder for a consequential project initiative whose destination is visible but route, product meaning, or architecture remains too foggy for one honest specification or agent session. Use only when the maintainer explicitly requests Wayfinder or accepts the router's recommendation. Do not use for ordinary brainstorming, a bounded large feature, current-project explanation, or product-code implementation. ---- - -# Shape Project Direction - -Wayfinder finds a route; it does not build the destination. It stores one -low-resolution map and bounded question issues inside the same central bundle -that later becomes the delivery specification. - -Read [the Wayfinder contract](references/direction-shaping-contract.md) before -charting or resuming. - -## Chart the map - -1. Reuse the initiative's active bundle or start one with `wfctl work start - <slug> --title "<destination>" --mode wayfinder`. Bind only already-known - evidence leaves; unknown future implementation repositories are not guessed. -2. Run `wfctl work context <id> --stage wayfind` and `wfctl work status <id>`. - Do not edit product source during this phase. -3. Run `wfctl work repositories <id>` and read what every bound repository - declares about itself — the instructions its maintainer wrote in its own - agent file, and the skills installed only there. A map charted from the - centre is charted without them otherwise, and they are exactly the rules - that decide whether a route is possible in that repository. Invoke - `align-project-knowledge` in the same pass: fog is only fog until you check - whether the project already answered it. `wfctl work map finish` refuses - until every bound repository is accounted for. -4. Name the destination first: what a finished map will make specifiable, for - whom, and the boundary it must not cross. -5. Explore breadth-first. Put a precise answerable question into a Wayfinder - issue. Put only still-unphraseable in-scope uncertainty into `map.md` fog. - Put ruled-out work under out of scope. -6. Create currently visible blockers before their dependants with `wfctl work - issue create --phase wayfinding --type - research|prototype|grilling|task`. Use `wfctl work issue block|unblock` when - later evidence changes an edge. Stop charting; do not resolve a normal issue - in the same session. - -If breadth-first exploration reveals no meaningful fog and the whole route fits -one session, stop and recommend ordinary `specify-project-change` instead. - -## Work one frontier question - -Run `wfctl work map status <id>`. Without a named issue, choose the first -frontier item. Read the map, selected issue, parent change, blockers, and -referenced artifacts completely; record current hash receipts. Claim before -work: - -```sh -wfctl work issue claim <id> <issue-id> --actor "agent:<identity>" -``` - -- `research` discovers an external or project fact without supplying product - authority; -- `prototype` creates a cheap artifact for a real human to react to; -- `grilling` resolves one product/domain decision with the maintainer, one - evidence-backed question at a time; -- `task` performs a prerequisite that makes a later decision possible. - -Ask one focused question at a time. Persist the answer before selecting another. - -Never answer the human side of a human-in-the-loop issue yourself. Complete at -most one non-research issue per session. Record the full answer and evidence in -the issue, then run `wfctl work issue complete`. The CLI adds only a named gist -to the map. Update newly visible issues and remove the corresponding fog so one -fact never lives in two competing places. - -## Hand off to specification - -The route is clear only when every Wayfinder issue is completed or explicitly -dropped, no in-scope fog remains, and the next bounded change can be specified -without guessing. Invoke `specify-project-change`: it must read all resolved -issues, synthesize stable acceptance criteria into `change.md`, obtain review, -and run `wfctl work map finish`. - -Do not jump from a map directly into implementation. The retained `map.md` is -decision lineage, while `change.md` becomes the current delivery contract. diff --git a/.agents/skills/shape-project-direction/agents/openai.yaml b/.agents/skills/shape-project-direction/agents/openai.yaml deleted file mode 100644 index 26653bd..0000000 --- a/.agents/skills/shape-project-direction/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Shape Project Direction" - short_description: "Resolve a large uncertain project direction" - default_prompt: "Use this skill to turn a broad, uncertain product or architecture initiative into a durable decision frontier before implementation." -policy: - allow_implicit_invocation: false diff --git a/.agents/skills/shape-project-direction/references/direction-shaping-contract.md b/.agents/skills/shape-project-direction/references/direction-shaping-contract.md deleted file mode 100644 index 1117873..0000000 --- a/.agents/skills/shape-project-direction/references/direction-shaping-contract.md +++ /dev/null @@ -1,59 +0,0 @@ -# Wayfinder contract - -## Entry test - -Use Wayfinder only when all are true: - -- the initiative can materially change product meaning, architecture, - ownership, or several Areas; -- the destination can be named, but multiple dependent choices still hide the - route; -- one honest specification cannot yet fit in a single well-reasoned session; -- the maintainer explicitly selected or accepted this mode. - -A large but bounded feature, migration, audit, or refactor belongs to ordinary -significant work. Brainstorming remains conversational until durable shaping is -actually wanted. - -## Map roles - -`map.md` is an index, not the store of every answer: - -- `destination` fixes what the route is finding and therefore its scope; -- `notes` holds standing domain and process context; -- `resolved` points by issue name to full issue resolutions; -- `fog` holds in-scope uncertainty that cannot yet be phrased precisely; -- `out_of_scope` holds work beyond the destination that never graduates. - -A precise unanswered question is an issue, even when blocked. A vague suspected -question remains fog. When resolution makes fog precise, create the new issue -and remove that fog item. - -## Issue roles - -- **Research (agent-driven):** establishes a fact from primary or project - sources. It cannot establish product authority. -- **Prototype (human-in-the-loop):** creates a cheap disposable artifact to - make a design question concrete. -- **Grilling (human-in-the-loop):** resolves a product, domain, or architecture - choice one focused question at a time. -- **Task (agent- or human-driven):** performs a prerequisite that exposes facts - needed by later decisions; it does not deliver the destination. - -The frontier contains ready, unclaimed issues whose blockers are completed. -Claim before work. Resolve one non-research issue per session. The full answer -and evidence live in that issue; the map stores only a gist and pointer. - -## Exit test - -Wayfinder is ready for specification only when: - -- destination and out-of-scope boundaries are explicit; -- every issue is completed or deliberately dropped with a reason; -- no in-scope fog remains; -- accepted language and decisions are distinguishable from proposals; -- the next bounded change can be specified without guessing. - -`specify-project-change` then reads the complete map bundle, synthesizes -`change.md`, and finishes the map into `full` or `slice`. It preserves `map.md` -as lineage and never bypasses specification into implementation. diff --git a/.agents/skills/specify-project-change/SKILL.md b/.agents/skills/specify-project-change/SKILL.md deleted file mode 100644 index 2f250c6..0000000 --- a/.agents/skills/specify-project-change/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: specify-project-change -description: Synthesize an already-discussed bounded change, or a resolved Wayfinder map, into the one central project specification. Use when the maintainer explicitly asks to write or refresh the spec, when a direction map is ready to become delivery work, or before splitting approved work into issues. Do not use to discover a huge unresolved direction, implement code, or create a second specification outside the active knowledge bundle. ---- - -# Specify Project Change - -Turn the context already earned through discussion, curated knowledge, and -source inspection into one buildable contract. Do not restart an interview or -discard decisions merely because a fresh template would be easier. - -Read [the specification contract](references/specification-contract.md) before -editing the bundle. - -## Bind and read - -1. Resolve the active change ID. If no significant-work bundle exists, invoke - `manage-project-work` to classify and start it first. -2. Run `wfctl work context <id> --stage shape`. For a resolved Wayfinder map, - run `--stage review` so every map issue and artifact is enumerated. -3. Read every required file completely, including content below long tables or - headings. Run `wfctl work review file <id> <path>` only after the whole file - has been read and reconciled. -4. Run `wfctl work status <id>`. Treat `Spec` as the only editable - specification and every `Code root` as an exact evidence workspace, never - as a path inferred from repository name. - -## Synthesize, do not invent - -Use the current discussion without asking the maintainer to repeat it. Reconcile -it with relevant curated knowledge and verified source reality. If code informs -the contract, invoke `analyze-with-graphify` in every relevant bound root, then -open the actual source locations. Invoke `align-project-knowledge` before -settling product or architecture meaning. - -Work spanning more than one repository is shaped here, at the centre, because -only the centre sees them all at once. What the centre does not see is what each -repository declares about itself: the instructions its maintainer wrote in its -own agent file, and the skills installed only there. Those are specific and -binding — one repository opens with a plan file to read first, another calls its -navigation rule BINDING — and a session that never entered the checkout has no -way to know they exist. - -```sh -wfctl work repositories <id> -``` - -This prints them without leaving the centre. Read every one, then account for -each bound repository before asking for approval: - -```sh -wfctl work repositories <id> --read <repository> --note "<what its rules require of this work>" -wfctl work repositories <id> --untouched <repository> --reason "<why this work does not reach it>" -``` - -The hash of its instructions and the list of its own skills are taken from the -checkout, not from you, so the receipt binds to what was there; if the -repository changes its rules afterwards the receipt is reported stale rather -than quietly wrong. Framing approval and `wfctl work map finish` both refuse -until every bound repository is one or the other. Saying nothing is not a third -option, and neither is a note that only says the file was opened. - -Update `change.md` with: - -- the problem and intended observable outcome; -- actors, current behavior, constraints, and explicit exclusions; -- approved product and engineering decisions with rationale; -- stable acceptance entries `AC-01`, `AC-02`, ... in frontmatter; -- the highest practical test seams and what behavior each seam proves; -- unresolved authority or facts, without guessing them away; -- the current ledger and structured resumable checkpoint. - -Acceptance criteria describe observable outcomes and boundary behavior, not a -file-by-file implementation plan. Preserve an ID when wording improves without -changing meaning. Retire or supersede changed meaning explicitly. - -Ask at most one blocking question at a time. Include verified facts, the -decision it unlocks, viable choices, and a recommendation. Persist the answer -before continuing. After each material edit or maintainer answer, refresh the -bundle checkpoint with `wfctl work checkpoint <id>`; run it last so its hash -binds the current `change.md` rather than an earlier draft. - -## Review and continue - -Present a compact framing packet — maintainer-facing, so the reader test in -`maintainer-review` governs every sentence: outcome, scope, exclusions, decisions, -acceptance IDs, test seams, risks, and unresolved work. Record only explicit -maintainer approval, and record it through the approval command rather than by -editing the receipt: - -```sh -wfctl work approve <id> --stage framing \ - --by human:<maintainer-id> \ - --note "<what was approved>" -``` - -Render the framing with `wfctl work ask <id>` and record their reply with -`--attested "<their words>" --session "<where>"`. A typed confirmation and a -`--token` matching `WFCTL_APPROVAL_TOKEN` remain available and are theirs to ask -for; do not send them to a second terminal by default. A hand-written -`maintainer_review.framing` receipt fails verification. Approving rewrites -`change.md`, so re-read it, refresh its review receipt, and refresh the -checkpoint afterwards. - -For Wayfinder, read every resolved issue in full, collapse its linked detail -into the specification, clear all legitimate fog, review every current bundle -file, then run: - -```sh -wfctl work map finish <id> --mode full|slice -``` - -The map remains as history; it is not copied into a parallel strategy file. -After approval, invoke `split-project-change` when the work needs multiple -fresh sessions. A small bounded change may remain entirely in `change.md`. diff --git a/.agents/skills/specify-project-change/agents/openai.yaml b/.agents/skills/specify-project-change/agents/openai.yaml deleted file mode 100644 index 8a5543a..0000000 --- a/.agents/skills/specify-project-change/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Specify Project Change" - short_description: "Synthesize one central change specification" - default_prompt: "Use $specify-project-change to synthesize the current discussion, project knowledge, and verified source context into the active central specification and refresh its checkpoint." -policy: - allow_implicit_invocation: false diff --git a/.agents/skills/specify-project-change/references/specification-contract.md b/.agents/skills/specify-project-change/references/specification-contract.md deleted file mode 100644 index 0bbdb26..0000000 --- a/.agents/skills/specify-project-change/references/specification-contract.md +++ /dev/null @@ -1,29 +0,0 @@ -# Specification contract - -The specification states what must become true and why. It is not a transcript, -an issue list, or an implementation diary. - -## Required synthesis - -- Explain the problem from the affected actor's perspective. -- Explain the outcome from that actor's perspective. -- Preserve the project's accepted domain language. -- Separate product behavior from engineering decisions without losing the link. -- Prefer existing high-level test seams; propose a new seam only when current - interfaces cannot prove the behavior safely. -- Record out-of-scope boundaries and known risks explicitly. -- Make every acceptance criterion observable, independently referencable, and - falsifiable. - -Do not include volatile source paths or large code snippets as the contract. -Exact source evidence belongs in progress and verification. A concise schema, -state machine, or type shape from a prototype may be retained when prose would -lose a settled decision; identify it as prototype-derived. - -## Wayfinder collapse - -The map is an index. Each completed issue owns the detailed answer. Synthesis -therefore requires reading all resolved issues, not expanding the one-line map -gists into guesses. Keep the map as lineage, carry accepted conclusions into -`change.md`, keep deferred questions visible, and do not create delivery issues -until this collapse is reviewed. diff --git a/.agents/skills/split-project-change/SKILL.md b/.agents/skills/split-project-change/SKILL.md deleted file mode 100644 index b8413ae..0000000 --- a/.agents/skills/split-project-change/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: split-project-change -description: Split one approved central change specification into dependency-aware tracer-bullet issues inside the same knowledge bundle. Use when the maintainer explicitly asks to create the execution breakdown, when delivery spans several safe agent sessions, or when parallel work needs an explicit frontier. Do not use before framing approval, for an unresolved Wayfinder map, or to create an external or leaf-local competing tracker. ---- - -# Split Project Change - -Create bounded execution units without fragmenting the specification or losing -acceptance coverage. - -Read [the issue-design contract](references/issue-design-contract.md) before -proposing the graph. - -## Read the approved contract - -1. Run `wfctl work context <id> --stage shape` and `wfctl work status <id>`. -2. Read `change.md` completely and confirm that framing is approved, the mode - is `full` or `slice`, and stable acceptance IDs exist. -3. Reuse current source and knowledge understanding. If the proposed breakdown - depends on implementation facts not yet checked, invoke Graphify-first - analysis in the exact relevant code roots before publishing tickets. - -## Draft the graph - -Prefer narrow complete tracer bullets: one issue produces independently -reviewable behavior across every necessary layer and fits in one fresh session. -Do not split work into database/API/UI horizontal layers merely because the -repository does. - -For each proposed issue show the maintainer: - -- a human-readable title; -- the complete behavior or decision it delivers; -- stable acceptance IDs it contributes to; -- exact repository identities it may touch; -- genuine blocking issues; -- whether it is small enough for one fresh context. - -Use expand-migrate-contract for a wide mechanical refactor that cannot keep the -system valid as independent vertical slices. Keep each migration batch bounded -by blast radius and make final contraction depend on every migration. - -Ask whether the granularity and dependency edges are right. Publish only the -approved graph. - -## Publish centrally - -Create issues in dependency order so blocker IDs already exist: - -```sh -wfctl work issue create <change-id> <slug> \ - --title "<title>" \ - --phase delivery \ - --type delivery \ - --satisfies AC-01 \ - --repository <repository-id> \ - --blocked-by ISSUE-001 -``` - -Repeat options as needed. Each issue receives its own ready checkpoint; never -create a second issue or progress file in a leaf. Run `wfctl work issue list -<change-id>` and confirm every acceptance ID is covered, the graph is acyclic, -and the frontier matches the intended concurrency. Refresh the parent -checkpoint last with the first executable frontier action. - -Do not implement during this skill. Hand a frontier issue to -`implement-work-item` in a fresh context. diff --git a/.agents/skills/split-project-change/agents/openai.yaml b/.agents/skills/split-project-change/agents/openai.yaml deleted file mode 100644 index c6cb2a7..0000000 --- a/.agents/skills/split-project-change/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Split Project Change" - short_description: "Split a change into checkpointed issues" - default_prompt: "Use $split-project-change to split the approved central specification into dependency-aware issues with one ready checkpoint each." -policy: - allow_implicit_invocation: false diff --git a/.agents/skills/split-project-change/references/issue-design-contract.md b/.agents/skills/split-project-change/references/issue-design-contract.md deleted file mode 100644 index 2cb0c00..0000000 --- a/.agents/skills/split-project-change/references/issue-design-contract.md +++ /dev/null @@ -1,27 +0,0 @@ -# Issue design contract - -An issue is an executable unit, not a copy of the parent spec. - -## Good issue - -- Delivers one complete behavior or resolves one explicit work prerequisite. -- Fits in one fresh agent session when possible. -- Names the acceptance IDs it contributes to. -- Declares only dependencies that genuinely prevent starting. -- Identifies repository scope without prescribing stale file paths. -- Can be verified independently at a public or project-approved seam. -- Leaves one precise, hash-bound checkpoint after every material turn. - -## Bad splits - -- one ticket per technical layer; -- one giant issue that silently relies on conversation memory; -- blockers used merely as ordering preferences; -- acceptance text duplicated and allowed to drift; -- source paths or snippets treated as permanent requirements; -- a local leaf checklist competing with the central bundle. - -Prefactoring may be its own prerequisite issue when it makes the later behavior -safe and easy. It must still define an observable structural outcome and fresh -checks. Wide refactors use expand-migrate-contract rather than pretending each -layer is independently shippable. diff --git a/.agents/skills/state-management/SKILL.md b/.agents/skills/state-management/SKILL.md deleted file mode 100644 index cf5bb12..0000000 --- a/.agents/skills/state-management/SKILL.md +++ /dev/null @@ -1,263 +0,0 @@ ---- -name: state-management -description: Use whenever work creates, changes, reviews, debugs, or consumes shared client state, especially Zustand stores, selectors, actions, store namespaces, feature or page stores, store factories and providers, persisted state, migrations, hydration guards, secure storage, authentication state, cross-store workflows, resets, or long-running client processes. Trigger when deciding whether state belongs in Zustand, TanStack Query, router params or search, React Hook Form, React state, or context, even if the request does not name a state-management library. ---- - -# State Management - -Use Zustand for **shared client-owned state**, not as the default owner of every -value. Choose the owner and lifetime before choosing store syntax. - -Adapt imports and storage implementations to the repository. Preserve these -ownership and security boundaries even when the local Zustand idiom differs. - -## Decide The Owner First - -Keep each kind of state with its real authority: - -- Refetchable external or backend data belongs to the query layer. Do not copy - query results into Zustand through `useEffect`. -- Resource identity belongs in path params. Filters, sorting, pagination, tabs, - and other navigable state belong in search params. -- Form fields, validation, dirty state, and submission state belong to the form. -- State used by one component belongs in React state. -- State shared only by one composable subtree usually belongs in context. -- Shared client state, imperative client state, state that crosses unrelated - React branches, and client processes that outlive one component may belong in - Zustand. - -Persistence is a separate decision. A value does not belong in Zustand merely -because it must survive a restart, and a Zustand value need not be persisted. - -Read `references/ownership-and-scope.md` when choosing between these owners or -between a singleton and a scoped store. - -## Build Bounded Capability Stores - -Create a store around one cohesive client capability. Do not merge unrelated -state merely to reduce the number of stores, and do not split one atomic -capability into a store per field. - -A module-level `create(...)` is a singleton regardless of where its file lives. -Use it only when the capability truly has one application-wide or -feature-wide lifetime. Use a `createStore` factory with context when state: - -- belongs to one mounted page, editor, or component instance; -- is initialized from props; -- must reset on unmount; -- may have multiple simultaneous instances; -- must be isolated per SSR request, test, or story. - -Locate the store at the narrowest boundary containing all of its consumers. -Follow **file-structure** for the concrete directory shape. - -## Choose The Store Definition Deliberately - -Prefer `create(combine(initialState, actions))` for a small store whose inferred -shape stays obvious. Use an explicit store type when it clarifies a factory, -public contract, complex async lifecycle, middleware stack, or process manager. -Never force `combine` merely to avoid writing a useful type. - -Keep ordinary state transitions beside their state. An action defined by one -store may read and mutate that store only; it must not import another store. -Keep a reusable initial state and provide an explicit reset when the capability -has a reset lifecycle. - -Do not store derived values that can be computed cheaply from current state. -Compute them in a selector or render instead of synchronizing them through an -effect. - -See `references/store-patterns.md` for simple, explicit, scoped, selector, and -reset examples. - -## Expose A Discoverable Namespace - -Expose the store through one capability namespace: - -```ts -export const preferencesStore = { - useStore: usePreferencesStore, -} -``` - -Selecting an ordinary field directly is the default: - -```ts -const theme = preferencesStore.useStore((state) => state.theme) -``` - -Do not generate one wrapper hook per field. Add a named hook only when it -expresses a meaningful derived concept, composes several stores, centralizes -equality behavior, or deliberately hides an unstable internal representation. - -A namespace may also expose feature-level commands and hooks. Treat the -namespace as the public facade of the capability, not as a claim that every -member is a literal Zustand action. - -## Subscribe Narrowly - -- Select the narrowest field or derived primitive the consumer needs. -- Use the repository's shallow-equality helper, such as `useShallow`, when a - selector returns an object, array, or other shallow-comparable collection. -- Avoid `useStore()` without a selector; it subscribes to every change. -- Never mutate objects, arrays, `Map`, or `Set` in place. Return a new reference. -- Inside React, subscribe through the hook. Outside React, use `getState()` or a - public command. -- Use direct `setState()` primarily for tests, stories, framework integration, - and controlled setup. Production mutations go through owned actions or - commands. - -## Keep Orchestration Outside Store Actions - -Feature-level commands and hooks may coordinate several stores, the query -client, storage capabilities, and transports when the workflow clearly belongs -to that feature. Logout is a typical example. - -Prefer a plain command for orchestration that does not require React, then wrap -it in a hook or mutation when the UI needs pending, error, or success state. -Keep navigation, toasts, and other caller-specific UX at the caller unless they -are an invariant of the workflow. - -Derived hooks may subscribe to multiple stores. This is composition, not a -store-to-store dependency. - -Read `references/processes-and-coordination.md` for command, logout, and -cross-store examples. - -## Persist Only An Explicit Contract - -Do not add `persist` by habit. For every persisted store: - -- give the storage key one stable owner; -- whitelist the minimal persisted shape with `partialize`; -- exclude actions, hydration state, in-flight state, caches, runtime handles, - access tokens, and other transient values; -- establish a version before the persisted contract ships; -- validate persisted `unknown` input rather than trusting a cast; -- define migrations for breaking shape changes; -- define a custom merge when nested defaults require more than Zustand's - shallow merge; -- use the repository's storage adapter rather than reading or parsing - Zustand's serialized value elsewhere. - -Choose failure behavior by data class. A disposable cache may reset explicitly. -A decrypt, corruption, or migration failure for credentials, user-created -offline data, or another critical store must surface as an error and must not -silently become “no saved state.” - -Do not rely on `await set(...)` as a portable persistence-completion contract. -When the next operation requires durable completion, put that guarantee behind -a dedicated, tested persistence capability. - -## Model Hydration As A Prerequisite - -Synchronous and asynchronous adapters hydrate differently. Do not add a -hydration gate when no consumer depends on it, and do not assume all persisted -stores hydrate asynchronously. - -For critical asynchronous state, model at least: - -```ts -type HydrationState = - | { status: "hydrating"; error: null } - | { status: "ready"; error: null } - | { status: "error"; error: unknown } -``` - -Use ordered guards when application regions depend on persisted state: - -```tsx -<SecureStoresGuard> - <SessionGuard> - <AccessGuard> - <CriticalDataGuard>{children}</CriticalDataGuard> - </AccessGuard> - </SessionGuard> -</SecureStoresGuard> -``` - -Each guard owns one prerequisite and either renders its loading, error, -blocked, or retry state or passes `children`. Outer guards establish the -invariants assumed by inner guards. Never interpret pre-hydration defaults as -logout, missing credentials, or permission denial. - -Read `references/persistence-and-security.md` before changing persistence, -hydration, authentication storage, encryption, or guards. - -## Keep Secrets Out Of Zustand When Possible - -Secure storage protects data at rest; it does not protect plaintext after a -secret is returned to renderer JavaScript. - -- Keep reactive session metadata in Zustand: status, account identity, - expiration, lock state, and hydration state. -- Keep a short-lived access token in private auth-client memory only when the - renderer must perform authenticated requests. It rarely needs reactivity. -- Keep refresh tokens in an OS-backed native vault or an HttpOnly backend - session where the platform permits. -- Keep private keys non-exportable behind a native or platform capability. - Expose operations such as `sign`, `decrypt`, or `refreshSession`, not - `getSecret` or `getPrivateKey`. -- Never call an adapter `secure` when it is only `localStorage`, ordinary - filesystem storage, or another unprotected backend. -- A storage read returns `null` only when data is genuinely absent. Decryption - or key-access failure must remain an error. - -If architecture forces a raw secret into JavaScript, keep it out of global -reactive state, persistence, devtools, logs, and long-lived strings. Limit it to -the narrowest operation and treat buffer clearing as best effort, not a -security guarantee. - -Follow **native-integration** for capability and RPC boundaries and -**logging** for keeping secrets out of diagnostic records and -**error-handling** for safe incident context. - -## Allow Explicit Process Stores - -A Zustand store may manage a long-running client process when the process: - -- outlives one component or route; -- needs imperative start, cancel, retry, resume, or reattach operations; -- publishes progress to unrelated consumers; -- has a client-owned lifecycle rather than query-owned server state. - -Model explicit status transitions and reject duplicate or stale work. Keep -`AbortController`, streams, sockets, and other runtime handles outside -observable state when consumers do not need them. Never persist those handles; -persist only a deliberate resume record through its owning storage capability. - -When transitions become complex, use an explicit reducer or state machine -inside the capability rather than hiding an implicit state machine in scattered -booleans. - -Read `references/processes-and-coordination.md` before creating a process store. - -## Verify The Boundary - -Before finishing state-management work: - -1. Confirm each value still has one owner. -2. Confirm URL, form, and query state were not duplicated into Zustand. -3. Confirm singleton versus scoped lifetime is intentional. -4. Inspect every React subscription for unnecessary breadth. -5. Exercise reset, logout, remount, and concurrent-operation behavior. -6. For persistence, test migration, corrupted input, missing data, hydration - failure, retry, and durable-write ordering where relevant. -7. For secrets, verify what crosses into renderer memory and whether a narrower - capability can keep it out. - -## Related Skills - -- **api-integration** owns external operations, server state, query identities, - mutations, invalidation, and auth transport behavior. -- **routing** owns path and search state with navigation meaning. -- **forms** owns form values, validation, submission, and field errors. -- **components** owns React-local state, context, composition, and effect - avoidance. -- **file-structure** owns the concrete placement of store files and private - implementation. -- **native-integration** owns OS-backed storage, cryptography, privileged - capabilities, and renderer bridges. -- **logging** owns diagnostic records and the rule that secrets never enter - them. -- **error-handling** owns incident-reporting policy and safe reporting context. diff --git a/.agents/skills/state-management/references/ownership-and-scope.md b/.agents/skills/state-management/references/ownership-and-scope.md deleted file mode 100644 index b56b011..0000000 --- a/.agents/skills/state-management/references/ownership-and-scope.md +++ /dev/null @@ -1,165 +0,0 @@ -# State Ownership And Scope - -Use this reference when deciding whether state belongs in Zustand and whether a -store should be a module singleton or a scoped instance. - -## Contents - -- [Ownership Matrix](#ownership-matrix) -- [Do Not Duplicate Query State](#do-not-duplicate-query-state) -- [Singleton Stores](#singleton-stores) -- [Scoped Store Factories](#scoped-store-factories) -- [Context Without Zustand](#context-without-zustand) - -## Ownership Matrix - -| State | Default owner | -| --- | --- | -| Refetchable backend or external data | Query layer | -| Resource identity | Route path params | -| Filters, sorting, pagination, tabs, view options | Route search params | -| Form values, validation, dirty and submit state | Form library | -| One component | React state or reducer | -| One composable subtree | React context | -| Shared client-owned state | Zustand | -| Long-running client process | Process store or dedicated client capability | -| Restart-surviving subset | Explicit persistence boundary | - -These are ownership defaults, not library prohibitions. Change one only when the -value has a different real authority, and document that authority. - -## Do Not Duplicate Query State - -Do not copy query data into a store: - -```ts -const query = useQuery(projectQueryOptions(projectId)) -const setProject = projectStore.useStore((state) => state.setProject) - -useEffect(() => { - if (query.data) setProject(query.data) -}, [query.data, setProject]) -``` - -Read the query directly. Keep only genuine client state in Zustand: - -```ts -const query = useQuery(projectQueryOptions(projectId)) -const selectedPanel = projectWorkspaceStore.useStore( - (state) => state.selectedPanel, -) -``` - -An offline editor, local draft authority, or client process may intentionally -own a snapshot. Make that ownership explicit; do not create it merely to avoid -reading the query cache. - -## Singleton Stores - -A module-level store is appropriate for a true singleton: - -```ts -const usePreferencesStore = create( - combine( - { theme: "system" as ThemePreference }, - (set) => ({ - setTheme: (theme: ThemePreference) => set({ theme }), - }), - ), -) - -export const preferencesStore = { - useStore: usePreferencesStore, -} -``` - -Typical singleton capabilities include application preferences, one active -session, and one application-wide background queue. - -Colocating this file with a page narrows its ownership but does not change its -runtime lifetime. It remains a module singleton. - -## Scoped Store Factories - -Use a factory when each mounted owner needs an independent instance: - -```tsx -import { - createContext, - type PropsWithChildren, - useContext, - useState, -} from "react" -import { createStore, useStore } from "zustand" - -type ProjectWorkspaceStore = { - projectId: string - selectedNodeId: string | null - selectNode: (nodeId: string | null) => void -} - -const createProjectWorkspaceStore = (projectId: string) => - createStore<ProjectWorkspaceStore>()((set) => ({ - projectId, - selectedNodeId: null, - selectNode: (selectedNodeId) => set({ selectedNodeId }), - })) - -type ProjectWorkspaceStoreApi = ReturnType< - typeof createProjectWorkspaceStore -> - -const ProjectWorkspaceStoreContext = - createContext<ProjectWorkspaceStoreApi | null>(null) - -export function ProjectWorkspaceStoreProvider({ - projectId, - children, -}: PropsWithChildren<{ projectId: string }>) { - const [store] = useState(() => createProjectWorkspaceStore(projectId)) - - return ( - <ProjectWorkspaceStoreContext.Provider value={store}> - {children} - </ProjectWorkspaceStoreContext.Provider> - ) -} - -export function useProjectWorkspaceStore<T>( - selector: (state: ProjectWorkspaceStore) => T, -) { - const store = useContext(ProjectWorkspaceStoreContext) - - if (!store) { - throw new Error( - "useProjectWorkspaceStore must be used within ProjectWorkspaceStoreProvider", - ) - } - - return useStore(store, selector) -} -``` - -Create the store once for that provider instance. Do not recreate it on every -render. Key or remount the owner deliberately when its identity changes. - -Use the same pattern for SSR request isolation. Never share a mutable -module-level store between server requests. - -## Context Without Zustand - -Do not add Zustand merely because several compound components share state: - -```tsx -const SelectionContext = createContext<SelectionContextValue | null>(null) -``` - -Prefer plain context when: - -- all consumers live under one obvious provider; -- no imperative access outside React is needed; -- update frequency and provider scope are controlled; -- the state should disappear with that subtree. - -Use a scoped Zustand store when selector-based subscriptions, imperative access, -or a substantial state transition model materially improves that subtree. diff --git a/.agents/skills/state-management/references/persistence-and-security.md b/.agents/skills/state-management/references/persistence-and-security.md deleted file mode 100644 index a31ca44..0000000 --- a/.agents/skills/state-management/references/persistence-and-security.md +++ /dev/null @@ -1,391 +0,0 @@ -# Persistence, Hydration, Guards, And Secrets - -Use this reference before implementing or reviewing persisted state, -authentication storage, secure adapters, migrations, hydration, or application -guards. - -## Contents - -- [Persist A Stable Subset](#persist-a-stable-subset) -- [Validate, Migrate, And Merge](#validate-migrate-and-merge) -- [Hydration State](#hydration-state) -- [Guard Composition](#guard-composition) -- [Storage Failure Semantics](#storage-failure-semantics) -- [Secret Placement](#secret-placement) -- [Platform Options](#platform-options) - -## Persist A Stable Subset - -Persist only state that must survive a restart: - -```ts -import { create } from "zustand" -import { - combine, - createJSONStorage, - persist, -} from "zustand/middleware" - -const usePreferencesStore = create( - persist( - combine( - { - theme: "system" as ThemePreference, - density: "comfortable" as DensityPreference, - previewTheme: null as ThemePreference | null, - }, - (set) => ({ - setTheme: (theme: ThemePreference) => set({ theme }), - }), - ), - { - name: "preferences", - version: 1, - storage: createJSONStorage(() => applicationStorage), - partialize: (state) => ({ - theme: state.theme, - density: state.density, - }), - }, - ), -) -``` - -`previewTheme`, actions, hydration state, request state, and runtime handles do -not persist. - -Do not read or parse the serialized Zustand value from another module. If -pre-React bootstrap needs a persisted preference, expose that through the -storage capability that owns the contract. - -## Validate, Migrate, And Merge - -Persisted data is untrusted `unknown` input. Validate it with the repository's -existing schema mechanism: - -```ts -const persistedPreferencesSchema = z.object({ - theme: z.enum(["system", "light", "dark"]), - density: z.enum(["compact", "comfortable"]), -}) - -type PersistedPreferences = z.infer< - typeof persistedPreferencesSchema -> -``` - -Use an explicit persisted return type: - -```ts -partialize: (state): PersistedPreferences => ({ - theme: state.theme, - density: state.density, -}) -``` - -Migration receives `unknown`: - -```ts -migrate: (persistedState, version) => { - if (version === 0) { - const legacy = legacyPreferencesSchema.parse(persistedState) - - return { - theme: legacy.colorMode, - density: "comfortable", - } - } - - return persistedPreferencesSchema.parse(persistedState) -} -``` - -Zustand's default merge is shallow. Merge nested defaults deliberately: - -```ts -merge: (persistedState, currentState) => { - const persisted = persistedWorkspaceSchema.parse(persistedState) - - return { - ...currentState, - workspace: { - ...currentState.workspace, - ...persisted.workspace, - }, - } -} -``` - -Do not add a migration function that silently converts every invalid critical -value into an empty default. Decide whether the data is disposable or whether -failure must block and offer recovery. - -## Hydration State - -Synchronous storage may hydrate during store creation. Asynchronous storage -hydrates later. Add a gate only when consumers must not observe defaults before -hydration. - -For simple noncritical state, a boolean may be sufficient: - -```ts -type HydrationState = { - hasHydrated: boolean -} -``` - -For critical state, preserve loading and error: - -```ts -type HydrationStatus = - | { status: "hydrating"; error: null } - | { status: "ready"; error: null } - | { status: "error"; error: unknown } -``` - -Several secure stores may share a hydration registry: - -```ts -type SecureStoresHydration = { - session: HydrationStatus - accounts: HydrationStatus -} -``` - -Set `skipHydration: true` when native preparation, an unlock step, SSR, or -another prerequisite must happen before the first read: - -```ts -persist(stateCreator, { - name: "session", - storage: createJSONStorage(() => secureSessionStorage), - skipHydration: true, -}) -``` - -Start hydration explicitly when the adapter requires preparation: - -```ts -export async function hydrateSessionStore() { - hydrationStore.useStore.getState().begin("session") - - try { - await sessionVault.prepare() - await sessionStore.useStore.persist.rehydrate() - hydrationStore.useStore.getState().succeed("session") - } catch (error) { - hydrationStore.useStore.getState().fail("session", error) - throw error - } -} -``` - -Keep the original error. Do not invent a normalized error representation solely -for hydration. - -Do not assume `await set(...)` is a portable durable-write contract. If a -workflow must wait until encrypted persistence completes before locking, -navigating, or making another request, expose and test that guarantee through -the persistence capability. - -## Guard Composition - -Compose one prerequisite per guard: - -```tsx -function Application({ children }: PropsWithChildren) { - return ( - <SecureStoresGuard> - <SessionGuard> - <AccessGuard> - <CriticalDataGuard> - <ApplicationLayout>{children}</ApplicationLayout> - </CriticalDataGuard> - </AccessGuard> - </SessionGuard> - </SecureStoresGuard> - ) -} -``` - -The order is the contract: - -1. Secure persistence is readable. -2. Session presence is known. -3. Authorization is settled. -4. Critical application data is available. -5. The application layout and its consumers may mount. - -Each guard selects only its prerequisite and owns its fallback: - -```tsx -function SecureStoresGuard({ children }: PropsWithChildren) { - const hydration = hydrationStore.useStore((state) => state.session) - - if (hydration.status === "hydrating") { - return <FullScreenSpinner /> - } - - if (hydration.status === "error") { - return ( - <SecureStorageError - error={hydration.error} - onRetry={hydrateSessionStore} - /> - ) - } - - return children -} -``` - -Do not let a guard infer logout, denial, or missing data from a value whose -prerequisite has not passed. A hydration error must not automatically clear the -session. - -Hydration may start in the application entrypoint so no synchronization effect -is needed: - -```ts -void hydrateSecureStores().catch(() => { - // The hydration registry retains the original error for its guard. -}) - -createRoot(rootElement).render(<Application />) -``` - -An established query layer is also acceptable for async bootstrap when its -pending, retry, and error lifecycle fits the operation. - -## Storage Failure Semantics - -Return `null` only for genuine absence: - -```ts -async function getItem(name: string) { - const encryptedValue = backingStorage.getItem(name) - - if (encryptedValue === null) return null - - return nativeSecurity.decrypt(encryptedValue) -} -``` - -Do not swallow decryption failure: - -```ts -// Wrong: this changes "unreadable" into "missing". -async function getItem(name: string) { - try { - return await decrypt(backingStorage.getItem(name)) - } catch { - return null - } -} -``` - -The false “missing” result can hydrate empty defaults and later overwrite -recoverable encrypted data. - -## Secret Placement - -Secure storage protects data at rest. Once plaintext is returned to renderer -JavaScript, it may be reachable through application code, devtools, heap -snapshots, injected code, logs, or accidental persistence. - -Prefer this separation: - -```text -Zustand - session status - account identity - expiration - lock and hydration state - -Private auth-client memory - short-lived access token, only if renderer requests require it - -Native or backend vault - refresh token - private keys - long-lived credentials -``` - -An access token rarely needs reactivity. Prefer a private provider when the -renderer must hold it: - -```ts -let accessToken: string | null = null - -export const accessTokenProvider = { - get: () => accessToken, - set: (value: string | null) => { - accessToken = value - }, - clear: () => { - accessToken = null - }, -} -``` - -This reduces accidental exposure but does not protect against full renderer -compromise. - -Do not export raw private keys. Keep them non-exportable and expose operations: - -```ts -type SigningCapability = { - createKey: () => Promise<{ - keyId: string - publicKey: string - }> - sign: (input: { - keyId: string - payload: Uint8Array - }) => Promise<Uint8Array> - deleteKey: (keyId: string) => Promise<void> -} -``` - -Zustand may keep `keyId`, `publicKey`, and readiness state. The private key stays -behind the capability. - -When a raw key must temporarily enter JavaScript, keep it in the narrowest -lexical scope, prefer mutable byte buffers over immutable strings, never place -it in Zustand or persistence, and clear buffers as best effort. JavaScript -runtimes may retain copies, so do not claim guaranteed zeroization. - -## Platform Options - -Inspect the repository's threat model, existing native capabilities, and the -current official platform documentation before choosing an adapter. Security -semantics and fallbacks vary by operating system and runtime version. - -Choose the strongest boundary the platform supports: - -- Browser with backend: prefer a Backend-for-Frontend or HttpOnly, Secure, - SameSite session so tokens never enter application JavaScript. -- Browser without backend mediation: keep short-lived access tokens in memory; - never use `localStorage` or `sessionStorage` for credentials; require refresh - token rotation or sender constraint when refresh tokens are issued. -- Web cryptography: prefer non-extractable `CryptoKey` handles for local key - operations. They prevent raw export but cannot prevent compromised same-origin - code from invoking an allowed operation. -- Electron or another desktop shell: keep secrets and cryptographic operations - in the privileged native/main capability. Use OS Keychain, DPAPI, Secret - Service, or a framework wrapper such as Electron `safeStorage`; verify that - the selected Linux backend is not a plaintext fallback. -- Apple platforms: prefer Keychain and non-exportable key operations, using - Secure Enclave where the required algorithm and lifecycle permit it. -- Android: prefer Android Keystore and hardware-backed, non-exportable keys when - available. -- React Native or Expo: use the established Keychain/Keystore-backed secure - storage adapter for small tokens and secrets; prefer native non-exportable key - APIs over storing raw PEM keys. - -For larger encrypted data, store ciphertext in ordinary application storage and -keep only its data-encryption or wrapping key in the OS vault. - -Expose narrow native methods such as `sign`, `refreshSession`, and -`clearSession`. Never expose a generic `getSecret` or an unrestricted IPC -surface. diff --git a/.agents/skills/state-management/references/processes-and-coordination.md b/.agents/skills/state-management/references/processes-and-coordination.md deleted file mode 100644 index eec5867..0000000 --- a/.agents/skills/state-management/references/processes-and-coordination.md +++ /dev/null @@ -1,233 +0,0 @@ -# Process Stores And Coordination - -Use this reference for long-running client processes, cross-store workflows, -logout, and feature-level commands. - -## Contents - -- [Process Store](#process-store) -- [Runtime Handles](#runtime-handles) -- [Stale Work And Reattachment](#stale-work-and-reattachment) -- [Cross-Store Commands](#cross-store-commands) -- [Hook Adapters](#hook-adapters) - -## Process Store - -Use a process store when work must continue independently of one component: - -```ts -type ExportProcessState = { - status: "idle" | "running" | "completed" | "failed" - progress: number - error: unknown -} - -type ExportProcessActions = { - start: (input: ExportInput) => Promise<void> - cancel: () => void - reset: () => void -} - -type ExportProcessStore = ExportProcessState & ExportProcessActions - -const initialState: ExportProcessState = { - status: "idle", - progress: 0, - error: null, -} - -let activeController: AbortController | null = null - -const useExportProcessStore = create<ExportProcessStore>()((set, get) => ({ - ...initialState, - - start: async (input) => { - if (get().status === "running") return - - const controller = new AbortController() - activeController = controller - set({ status: "running", progress: 0, error: null }) - - try { - await exportClient.run(input, { - signal: controller.signal, - onProgress: (progress) => set({ progress }), - }) - - if (activeController !== controller || controller.signal.aborted) return - - set({ status: "completed", progress: 1 }) - } catch (error) { - if (activeController !== controller || controller.signal.aborted) return - - set({ status: "failed", error }) - } finally { - if (activeController === controller) activeController = null - } - }, - - cancel: () => { - activeController?.abort() - activeController = null - set(initialState) - }, - - reset: () => { - if (get().status === "running") return - set(initialState) - }, -})) - -export const exportProcessStore = { - useStore: useExportProcessStore, -} -``` - -The identity check prevents a late completion from an older operation from -overwriting a newer run. - -## Runtime Handles - -Keep runtime handles outside observable state unless consumers genuinely need -them: - -- `AbortController`; -- sockets and streams; -- timers; -- imperative clients; -- native resource handles; -- promises and async iterators. - -They are implementation details, are usually non-serializable, and must never -enter persisted state. - -If the process must survive a renderer restart, persist a deliberate resume -record through the process's storage capability: - -```ts -type ResumeRecord = { - runId: string - resourceId: string - startedAt: string -} -``` - -Do not persist the controller, stream, client, or accumulated implementation -object graph. - -## Stale Work And Reattachment - -Long-running stores must define: - -- what happens when `start` is called twice; -- how cancellation settles state; -- whether a finished result may update a newer run; -- whether restart means reattach, resume, reconcile, or fail; -- who owns any persisted resume record; -- when retry is allowed. - -Prefer explicit status or transition models over independent booleans: - -```ts -type ProcessStatus = - | { type: "idle" } - | { type: "running"; runId: string } - | { type: "resumable"; runId: string } - | { type: "completed" } - | { type: "failed"; error: unknown } -``` - -Use a reducer or state machine when valid transitions are no longer obvious -from a small store. - -## Cross-Store Commands - -A store action owns only its own state: - -```ts -const useSessionStore = create( - combine(sessionInitialState, (set) => ({ - clear: () => set(sessionInitialState), - })), -) -``` - -Do not import peer stores into that state creator. Coordinate them in a -feature-level command: - -```ts -export async function logout() { - await queryClient.cancelQueries() - queryClient.clear() - - sessionStore.useStore.getState().clear() - userStore.useStore.getState().clear() - notificationsStore.useStore.getState().clear() - - await sessionVault.clearSession() -} -``` - -The explicit list is intentional. Avoid a generic reset registry until several -real workflows prove that it improves ownership rather than hiding it. - -The command may live in the session capability and appear in its namespace: - -```ts -export const sessionStore = { - useStore: useSessionStore, - logout, -} -``` - -This is allowed because the namespace is a feature facade. `logout` is not -pretending to be an action inside the Zustand state creator. - -If several stores must always change atomically, reconsider their boundary. -They may be slices of one capability rather than independent stores. - -## Hook Adapters - -Wrap a plain command when React needs mutation lifecycle: - -```ts -function useLogoutMutation() { - return useMutation({ - mutationKey: ["session", "logout"], - mutationFn: logout, - }) -} - -export const sessionStore = { - useStore: useSessionStore, - logout, - useLogoutMutation, -} -``` - -Keep caller-specific navigation and feedback at the caller: - -```ts -const logoutMutation = sessionStore.useLogoutMutation() - -const handleLogout = async () => { - await logoutMutation.mutateAsync() - await router.invalidate() -} -``` - -A feature-level hook may compose multiple stores reactively: - -```ts -function useCanStartExport() { - const projectId = projectStore.useStore((state) => state.selectedId) - const processStatus = exportProcessStore.useStore( - (state) => state.status, - ) - - return projectId !== null && processStatus === "idle" -} -``` - -This is allowed. The state creators remain independent; the hook owns the -composition. diff --git a/.agents/skills/state-management/references/store-patterns.md b/.agents/skills/state-management/references/store-patterns.md deleted file mode 100644 index 15cc8f1..0000000 --- a/.agents/skills/state-management/references/store-patterns.md +++ /dev/null @@ -1,211 +0,0 @@ -# Store Patterns - -Use this reference for ordinary store definitions, namespaces, selectors, and -reset behavior. - -## Contents - -- [Simple Inferred Store](#simple-inferred-store) -- [Explicit Store Contract](#explicit-store-contract) -- [Namespace And Selectors](#namespace-and-selectors) -- [Immutable Updates](#immutable-updates) -- [Reset](#reset) - -## Simple Inferred Store - -Use `combine` when inference keeps a small store clearer: - -```ts -import { create } from "zustand" -import { combine } from "zustand/middleware" - -const initialState = { - selectedId: null as string | null, - expandedIds: [] as string[], -} - -const useNavigationStore = create( - combine(initialState, (set) => ({ - select: (selectedId: string | null) => set({ selectedId }), - expand: (id: string) => - set((state) => ({ - expandedIds: state.expandedIds.includes(id) - ? state.expandedIds - : [...state.expandedIds, id], - })), - reset: () => set({ ...initialState, expandedIds: [] }), - })), -) - -export const navigationStore = { - useStore: useNavigationStore, -} -``` - -Do not use state replacement with an inferred `combine` store. Replacing only -the initial-state shape can remove its actions. - -## Explicit Store Contract - -Use an explicit contract when it documents a factory, async lifecycle, public -package surface, or complex store better than inference: - -```ts -type UploadState = { - status: "idle" | "uploading" | "completed" | "failed" - progress: number - error: unknown -} - -type UploadActions = { - setProgress: (progress: number) => void - fail: (error: unknown) => void - complete: () => void - reset: () => void -} - -type UploadStore = UploadState & UploadActions - -const initialUploadState: UploadState = { - status: "idle", - progress: 0, - error: null, -} - -const useUploadStore = create<UploadStore>()((set) => ({ - ...initialUploadState, - setProgress: (progress) => set({ status: "uploading", progress }), - fail: (error) => set({ status: "failed", error }), - complete: () => set({ status: "completed", progress: 1 }), - reset: () => set(initialUploadState), -})) -``` - -Keep useful types beside the capability. Do not move them into a generic -`types.ts` file. - -## Namespace And Selectors - -Expose one discoverable namespace: - -```ts -const useStore = create(/* ... */) - -export const workspaceStore = { - useStore, -} -``` - -Select ordinary fields directly: - -```tsx -const selectedId = workspaceStore.useStore((state) => state.selectedId) -const select = workspaceStore.useStore((state) => state.select) -``` - -Select several fields with shallow equality: - -```tsx -const { status, progress } = uploadStore.useStore( - useShallow((state) => ({ - status: state.status, - progress: state.progress, - })), -) -``` - -Do not subscribe to the whole store: - -```tsx -// Avoid: rerenders for every store change. -const store = uploadStore.useStore() -``` - -Create a named hook when it owns meaningful logic: - -```ts -const useCanCancelUpload = () => - uploadStore.useStore( - (state) => state.status === "uploading" && state.progress < 1, - ) -``` - -A named hook may compose stores without creating a dependency between their -state creators: - -```ts -const useCanOpenWorkspace = () => { - const isAuthenticated = sessionStore.useStore( - (state) => state.status === "authenticated", - ) - const projectId = projectStore.useStore((state) => state.selectedId) - - return isAuthenticated && projectId !== null -} -``` - -Outside React, prefer a public command or use `getState()`: - -```ts -workspaceStore.useStore.getState().select(projectId) -``` - -## Immutable Updates - -Return new references: - -```ts -set((state) => ({ - items: [...state.items, item], -})) -``` - -Create new `Map` and `Set` instances: - -```ts -set((state) => ({ - selectedIds: new Set(state.selectedIds).add(id), -})) -``` - -Never mutate and return the existing reference: - -```ts -// Wrong: subscribers may not observe a change. -set((state) => { - state.selectedIds.add(id) - return { selectedIds: state.selectedIds } -}) -``` - -## Reset - -Model reset as an owned action: - -```ts -const createInitialState = (): NavigationState => ({ - selectedId: null, - expandedIds: new Set(), -}) - -const useNavigationStore = create<NavigationState & NavigationActions>()( - (set) => ({ - ...createInitialState(), - reset: () => set(createInitialState()), - }), -) -``` - -Use a factory when initial state contains mutable collections so reset never -reuses a previously mutated reference. - -Direct `setState()` is acceptable for tests and stories: - -```ts -beforeEach(() => { - useNavigationStore.setState(createInitialState()) -}) -``` - -Do not replace state with a state-only object because that removes actions. -Reset through the owned action when it represents the public test contract. diff --git a/.agents/skills/user-feedback/SKILL.md b/.agents/skills/user-feedback/SKILL.md deleted file mode 100644 index e353abd..0000000 --- a/.agents/skills/user-feedback/SKILL.md +++ /dev/null @@ -1,217 +0,0 @@ ---- -name: user-feedback -description: Use whenever work adds, changes, reviews, or debugs user-facing outcome feedback such as error, success, warning, or informational messages; toasts, snackbars, alerts, banners, dialogs, inline errors, empty or failed regions, form submission feedback, retry actions, crash or initialization fallbacks, domain-outcome presentation, or mapping technical failures to localized product copy. Trigger even when the request only mentions showError, showSuccess, notifications, server messages, error.message, mutation onError or onSuccess UX, or choosing how and where to tell the user what happened. ---- - -# User Feedback - -Present one clear outcome at the boundary that owns the user interaction. Choose -the surface from the duration, scope, severity, and recovery needs of the -message; do not route every outcome through a toast. - -Feedback is presentation. It does not decide whether a failure is logged, -reported as an incident, retried, or converted. - -## Inspect The Existing Feedback System - -Before adding feedback: - -1. Find the repository's UI primitives and existing toast, snackbar, banner, - alert, dialog, and error-region components. -2. Find any feedback facade or event bus and the runtime composition root that - renders it. -3. Check how localization, accessibility announcements, durations, actions, and - duplicate messages are handled. -4. Trace whether the same interaction already presents inline state or - navigation that makes another message redundant. -5. Find the technical error owner in **error-handling** before adding failure - feedback. - -Reuse the established presentation path. Do not introduce another toast library -or global event bus for one feature. - -## Choose The Smallest Correct Surface - -Use the surface that matches the problem: - -| Surface | Prefer for | -| --- | --- | -| Field message | Validation or server failure tied to one form field | -| Inline region | A failed or unavailable section with local retry | -| Toast or snackbar | A transient action outcome that does not block work | -| Banner | Persistent page- or application-level degraded state | -| Dialog or native alert | A blocking decision or consequence requiring acknowledgement | -| Route or subtree fallback | A render failure that invalidates that region | -| Application fallback | Initialization or unrecoverable root failure | - -Do not use a transient toast for a persistent failure. Do not replace an entire -page when only one region failed. Do not show both an inline message and a toast -for the same outcome unless they serve demonstrably different purposes. - -Read `references/presentation-patterns.md` for adaptable mapping, inline, -mutation, and fallback examples. - -## Use Product Copy, Not Technical Messages - -Do not show `error.message` to the user by default. It may be technical, -unstable, unlocalized, unsafe, or meaningless outside developer diagnostics. - -Prefer: - -1. a localized message selected by a known typed outcome; -2. a user-safe message explicitly guaranteed by the external contract; -3. a localized operation-specific fallback. - -Do not string-match a technical message to choose feedback when a status, code, -or discriminant exists. Do not expose stack traces, request bodies, provider -responses, identifiers the user cannot act on, or secret material. - -## Map Meaningful Outcomes Explicitly - -Keep feedback mapping close to the feature or shared domain boundary that owns -the meaning: - -```ts -function getProjectUpdateFeedback(error: unknown): Feedback { - if (isApprovalRequired(error)) { - return { - kind: "warning", - message: translate("Approval is required before this change can apply."), - } - } - - return { - kind: "error", - message: translate("Unable to update the project."), - } -} -``` - -Promote a mapper to shared `common` code only when genuinely different -consumers need the same meaning. Follow **file-structure** for its placement. - -Keep mapping pure: it selects presentation data. It must not report, log, -navigate, mutate state, or emit the feedback itself. - -## Keep Feedback And Reporting Independent - -Avoid APIs such as: - -```ts -showError(error, { report: true }) -``` - -A presentation helper cannot know whether a query cache, global listener, or -caller already reported the failure. Hidden reporting creates duplicates and -makes UI code control observability policy. - -When one interaction boundary owns both decisions, keep them visible: - -```ts -catch (error) { - reportError(error, { - module: "projects", - context: { projectId }, - }) - - showError({ - message: translate("Unable to update the project."), - }) -} -``` - -If a central observer already reports the failure, call only the feedback path -locally. - -## Treat Success As Optional Feedback - -Do not emit a success toast after every completed operation. - -Skip it when success is already obvious because the UI navigated, closed, -updated the resource, or displayed the new state. Use explicit success -feedback when completion would otherwise be ambiguous, delayed, performed in -the background, or especially consequential. - -Success copy should state the completed result, not merely `"Success"`. - -## Keep Validation Near The Field - -Client validation and server field errors belong beside their fields. Do not -replace them with a global toast. - -Use a form-level or toast failure only for a submission problem that cannot be -assigned to one field. Present one path: after mapping a server error to a -field, do not also show the same failure as a general error. - -Follow **forms** for schemas, React Hook Form, `setError`, submission state, and -field composition. - -## Make Recovery Actionable - -When the user can recover, present the relevant action: - -- retry the failed region or operation; -- reload or restart after an unrecoverable boundary; -- return to a stable route; -- reauthenticate when the session is no longer valid; -- open the resource created by an asynchronous or approval flow; -- dismiss a transient message. - -Do not offer retry when the operation is unsafe to repeat or when -**error-handling** has not established a retry policy. - -Preserve the user's entered data and surrounding usable state whenever -possible. - -## Handle Query And Mutation Feedback Locally - -Central Query or Mutation cache callbacks may own technical reporting, but they -do not know whether a toast, inline state, or no message is appropriate. - -- Present initial query failure in the failed region. -- Preserve cached data during a background refetch failure when possible. -- Present mutation feedback at the interaction boundary. -- Avoid global automatic error toasts for all queries and mutations. -- Keep cache invalidation and canonical lifecycle behavior in - **api-integration**. - -## Keep Feedback Accessible And Localized - -- Use the existing localization system for product copy. -- Ensure dynamic updates are announced through the established accessible - toast, live-region, alert, or native mechanism. -- Keep actionable controls keyboard and screen-reader reachable. -- Do not rely on color or an icon alone to communicate severity. -- Give persistent messages a visible dismissal or recovery path when - appropriate. -- Avoid durations too short for the message or action. - -## Verify The Result - -Before finishing: - -- one outcome produces one presentation; -- the surface matches the scope and persistence of the outcome; -- technical `error.message` is not exposed accidentally; -- copy is localized and actionable; -- known outcomes use typed fields rather than string matching; -- success feedback is not redundant with visible state; -- field errors remain attached to fields; -- feedback does not secretly log or report; -- retry is safe and owned by the technical error flow; -- accessibility and dismissal behavior match existing primitives. - -## Related Skills - -- Catch ownership, reporting, retry, cancellation, and boundaries → - **error-handling**. -- Form validation, submission, and server field errors → **forms**. -- UI primitives, composition, async-region rendering, and fallback components - → **components**. -- Query and mutation lifecycle, typed transport errors, and invalidation → - **api-integration**. -- Translation keys, interpolation, plurals, and locale-reactive copy → - **localization**. -- Placement and promotion of shared feedback modules → **file-structure**. -- Feedback notifications arriving from a native host → - **native-integration**. diff --git a/.agents/skills/user-feedback/agents/openai.yaml b/.agents/skills/user-feedback/agents/openai.yaml deleted file mode 100644 index 2e41ee4..0000000 --- a/.agents/skills/user-feedback/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "User Feedback" - short_description: "Safe and contextual outcome feedback" - default_prompt: "Use $user-feedback to design or review user-facing success, warning, error, and recovery feedback." diff --git a/.agents/skills/user-feedback/references/presentation-patterns.md b/.agents/skills/user-feedback/references/presentation-patterns.md deleted file mode 100644 index 1fab98d..0000000 --- a/.agents/skills/user-feedback/references/presentation-patterns.md +++ /dev/null @@ -1,173 +0,0 @@ -# User Feedback Presentation Patterns - -Adapt these examples to the repository's existing primitives, localization, and -error contracts. The names are illustrative. - -## Contents - -- [Pure Feedback Mapping](#pure-feedback-mapping) -- [Mutation Feedback Without Duplicate Reporting](#mutation-feedback-without-duplicate-reporting) -- [Inline Query Failure](#inline-query-failure) -- [Server Field Error](#server-field-error) -- [Blocking And Root Failures](#blocking-and-root-failures) - -## Pure Feedback Mapping - -Map a known outcome without causing side effects: - -```ts -type Feedback = - | { - kind: "error" - message: string - } - | { - kind: "warning" - message: string - action?: { - label: string - href: string - } - } - -function getSaveFeedback(error: unknown): Feedback { - if (isApprovalRequired(error)) { - return { - kind: "warning", - message: translate("Your change was submitted for approval."), - action: { - label: translate("View request"), - href: `/requests/${error.requestId}`, - }, - } - } - - return { - kind: "error", - message: translate("Unable to save your changes."), - } -} -``` - -The renderer or feedback facade decides how to display the returned structure. -The mapper does not report the error or navigate. - -## Mutation Feedback Without Duplicate Reporting - -When the Query mutation cache already owns technical reporting: - -```ts -try { - await updateProject.mutateAsync({ - projectId, - name, - }) - - closeDialog() -} catch (error) { - const feedback = getSaveFeedback(error) - showFeedback(feedback) -} -``` - -There is no success toast because closing the dialog and updating the project -already make completion visible. - -When no central reporter observes this imperative operation: - -```ts -try { - await exportReport(reportId) - showSuccess(translate("Report exported.")) -} catch (error) { - reportError(error, { - module: "report-export", - context: { reportId }, - }) - - showError({ - message: translate("Unable to export the report."), - }) -} -``` - -## Inline Query Failure - -Keep a local failure inside the region it invalidates: - -```tsx -const content = (() => { - if (query.isPending) { - return <UiSkeleton /> - } - - if (query.isError && query.data === undefined) { - return ( - <UiAlert variant="error"> - <UiAlertTitle>{translate("Projects could not be loaded.")}</UiAlertTitle> - <UiAlertAction onClick={() => void query.refetch()}> - {translate("Try again")} - </UiAlertAction> - </UiAlert> - ) - } - - if (query.data.length === 0) { - return <ProjectsEmptyState /> - } - - return <ProjectsList projects={query.data} /> -})() -``` - -A background refetch failure with existing `query.data` should normally keep -the list visible. Add a subtle stale or retry indication only when it helps the -user. - -## Server Field Error - -Attach a known field failure to the field and stop: - -```ts -catch (error) { - if (isNameTakenError(error)) { - setError("name", { - message: translate("This name is already in use."), - }) - return - } - - showError({ - message: translate("Unable to save the project."), - }) -} -``` - -Do not also show a general toast after setting the field error. - -## Blocking And Root Failures - -Use a dialog or native alert when the user must acknowledge a consequence or -choose an action before continuing. - -Use a route, subtree, or application fallback when rendering or initialization -cannot continue. Keep the fallback stable and offer the narrowest valid -recovery action: - -```tsx -function InitializationFallback() { - return ( - <UiResult> - <UiResultTitle>{translate("The application could not start.")}</UiResultTitle> - <UiResultDescription> - {translate("Restart the application and try again.")} - </UiResultDescription> - <UiButton onClick={restartApplication}> - {translate("Restart")} - </UiButton> - </UiResult> - ) -} -``` - -Do not accompany the fallback with a duplicate toast. diff --git a/.agents/skills/verify-knowledge-quality/SKILL.md b/.agents/skills/verify-knowledge-quality/SKILL.md deleted file mode 100644 index 1e445f0..0000000 --- a/.agents/skills/verify-knowledge-quality/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: verify-knowledge-quality -description: Perform the mandatory two-axis semantic gate for curated workflow knowledge before a document becomes stable or is reported complete. Use after creating or materially editing any product, engineering, decision, reference, or uncertainty concept, or for an explicit quality audit. Review authority and truth independently from reader communication, then bind both passes to one unchanged content hash. Do not use for ordinary explanation and do not let deterministic validation, polished prose, or a single self-review satisfy both axes. ---- - -# Verify Knowledge Quality - -Act as an adversarial review coordinator, not the author defending the draft. -Truth and communication are different failure surfaces; neither may compensate -for the other. - -Read [the quality rubric](references/quality-rubric.md) before the first review -in a session. - -## Freeze the review target - -1. Read the complete target document, not a snippet. -2. Read its parent Area index, every linked current product or engineering - counterpart, every current decision that governs it, and every material - authoritative source. -3. For code-backed claims, invoke `analyze-with-graphify` in the exact pinned - leaf and directly inspect the cited source, tests, and necessary runtime - evidence. -4. Run `wfctl knowledge validate --concept <path>` and distinguish structural - failures from semantic failures. Structural success is necessary but never - sufficient. A page still drafted under a bundle's `promotion/` directory has - no corpus position yet, so its structural validation runs when it is - promoted; a refusal there writes nothing and leaves the page where it is. -5. Run `wfctl knowledge hash --concept <path>` and pin the candidate content - hash before semantic review. The path may be a draft: the hash reads - frontmatter and body, never location, and a promoted page is copied byte for - byte, so a seal bound to the draft still matches once it lands. - -## Run two independent axes - -1. Apply [the authority and truth review](references/authority-review.md). -2. Separately apply - [the reader communication review](references/reader-communication-review.md) - for the declared view and audience. -3. Use independent reviewer contexts when the runtime safely provides them. - Otherwise perform two explicitly separated passes from their own checklists - and evidence packets. Do not reuse the first pass's verdict as evidence for - the second. -4. Search across both passes for omitted exceptions, unsupported present - tense, mixed audiences, hidden implementation detail, intent inferred from - code, history presented as current, and claims broader than evidence. -5. Return one review packet with: - - result: passed or failed; - - separate authority-truth and reader-communication results; - - each failed, uncertain, unread, or blocked check; - - exact evidence and conflicting evidence; - - the smallest correction; - - authority needed from the maintainer, if any. -6. Recompute the content hash. If it changed, discard both passes and rerun - them on the new revision. -7. Do not write a passed receipt while any item is failed, uncertain, unread, - or blocked. - -## Record a passed receipt - -After all substantive content is final: - -1. Run `wfctl knowledge hash --concept <path>`. -2. Set: - -```yaml -x-wf: - quality: - status: passed - by: "<producer>/<version>" - at: "<ISO-8601>" - content_hash: "<wfctl knowledge hash output>" - checks: - - factuality - - audience-fit - - abstraction - - completeness - - delivery-state - axes: - authority-truth: - status: passed - by: "<producer>/<version>" - at: "<ISO-8601>" - content_hash: "<same wfctl knowledge hash output>" - reader-communication: - status: passed - by: "<producer>/<version>" - at: "<ISO-8601>" - content_hash: "<same wfctl knowledge hash output>" -``` - -3. Use the same hash in the normal `verified` event after the applicable - machine or human authority review. -4. Re-run `wfctl knowledge validate --concept <path>`. A material edit changes - the hash and invalidates both receipts. - -The receipt proves that the declared review was performed against one exact -document revision. It does not create authority and does not make an incorrect -review true. diff --git a/.agents/skills/verify-knowledge-quality/agents/openai.yaml b/.agents/skills/verify-knowledge-quality/agents/openai.yaml deleted file mode 100644 index 7d58a0f..0000000 --- a/.agents/skills/verify-knowledge-quality/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Verify Knowledge Quality" - short_description: "Review truth and communication independently" - default_prompt: "Use $verify-knowledge-quality to run separate authority-truth and reader-communication reviews against one unchanged knowledge content hash." diff --git a/.agents/skills/verify-knowledge-quality/references/authority-review.md b/.agents/skills/verify-knowledge-quality/references/authority-review.md deleted file mode 100644 index 47ce721..0000000 --- a/.agents/skills/verify-knowledge-quality/references/authority-review.md +++ /dev/null @@ -1,35 +0,0 @@ -# Authority and truth review - -Review whether the document is entitled to make each claim. Ignore elegance, -tone, and readability except where ambiguity changes meaning. - -## Evidence packet - -Read: - -- the complete target; -- every governing current decision; -- every cited authoritative source in full enough to test the claim; -- current counterpart views and contradictory evidence; -- pinned source, tests, and runtime receipts for implementation claims. - -Retrieval snippets, raw, intake prose, compiled graphs, and agent summaries are -navigation only. - -## Checks - -- Split material statements into atomic claims. -- Match each claim to the correct authority class. -- Confirm source identity, revision, scope, and freshness. -- Ensure accepted intent, observed delivery, and alignment remain independent. -- Reject claims broader than their evidence. -- Find missing conditions, exceptions, failure modes, non-goals, and - contradictions. -- Verify that history is historical and current truth is current. -- Verify decision lineage and explicit maintainer authority for normative - meaning. -- Treat negative or absence claims as unproven without complete applicable - coverage. - -Return `passed`, `failed`, `uncertain`, or `blocked` per check. A missing or -unread source cannot pass. diff --git a/.agents/skills/verify-knowledge-quality/references/quality-rubric.md b/.agents/skills/verify-knowledge-quality/references/quality-rubric.md deleted file mode 100644 index d63ef5f..0000000 --- a/.agents/skills/verify-knowledge-quality/references/quality-rubric.md +++ /dev/null @@ -1,102 +0,0 @@ -# Knowledge quality rubric - -Every item is `passed`, `failed`, `uncertain`, or `blocked`. Only an all-passed -review may create a quality receipt. - -Run the common truth checks as the `authority-truth` axis and the view-specific -checks as the `reader-communication` axis. Bind both results to the same -unchanged content hash. Deterministic validation is a separate structural -gate. - -## Authority-truth axis - -### Factuality - -- Every material current claim has matching authority. -- Sources were read directly and match the exact revision. -- Raw, intake, retrieval snippets, generated graphs, and agent prose are not - treated as authority. -- Conflicting evidence and uncertainty are visible. -- Claims do not exceed the scope of their evidence. - -### Delivery state - -- Accepted intent, observed delivery, and alignment are independent. -- Present tense does not imply unavailable behavior. -- `absent`, `partial`, `implemented`, `verified`, `retired`, and `unknown` - match the actual evidence. -- Planned and rejected behavior remains outside current knowledge. - -### Completeness - -- Important rules, outcomes, boundaries, exceptions, non-goals, failure modes, - and affected relationships were not dropped. -- The document is the smallest coherent unit, not a fragment that hides a - material condition. -- Current decisions and supersession lineage are linked. - -### Freshness and lineage - -- `generated.at`, sources, realization assessment, and linked decisions refer - to the same current state. -- Historical explanation is clearly historical. -- A changed decision updates the current view and preserves predecessor - lineage. - -## Reader-communication axis - -### Product-view checks - -### Audience fit - -- A product manager or client can understand the main answer without - engineering knowledge. -- Domain terms are explained. -- The document leads with product outcome and observable behavior. -- Examples are domain examples, not code or API examples. - -### Abstraction - -- No code, identifiers, paths, endpoints, schemas, protocols, storage - mechanisms, package names, or implementation walkthroughs appear. -- `Engineering details` is link-only. -- Replacing the implementation without changing behavior would not require - rewriting the product explanation. -- Simplification did not erase a rule or exception. - -### Engineering-view checks - -### Audience fit - -- An engineer or operator can locate ownership, implementation, flow, - contracts, failure behavior, and verification. -- Technical terms are precise and useful for maintenance. - -### Abstraction - -- Product meaning is linked, not reconstructed from code. -- The document explains implementation rather than duplicating product prose. -- Technical detail is proportional to maintenance and verification needs. -- Repository, revision, paths, tests, and runtime evidence are pinned where - material. - -### Decision-view checks - -- Context, exact decision, rationale, consequences, affected knowledge, and - lineage are present. -- Alternatives, transition, and unresolved questions are concise and honest - when material; the document does not invent ceremonial content. -- Product effect is understandable without implementation detail. -- Technical consequences link to engineering knowledge. -- Supersession is reciprocal, acyclic, and leaves one stable current record. - -## Evaluation discipline - -This rubric follows agent-evaluation practice: inspect both outcome and -process, combine deterministic checks with semantic and human review, and test -real failure cases rather than relying on self-assessment. - -- Anthropic agent evals: - https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents -- Anthropic skill creation and baseline comparison: - https://github.com/anthropics/skills/blob/main/skills/skill-creator/SKILL.md diff --git a/.agents/skills/verify-knowledge-quality/references/reader-communication-review.md b/.agents/skills/verify-knowledge-quality/references/reader-communication-review.md deleted file mode 100644 index f26e892..0000000 --- a/.agents/skills/verify-knowledge-quality/references/reader-communication-review.md +++ /dev/null @@ -1,40 +0,0 @@ -# Reader communication review - -Review whether the intended reader can recover the correct meaning without -hidden context. Do not accept a document merely because its evidence is strong. - -## Product view - -Evaluate as a product manager, client, maintainer, or domain expert: - -- the purpose and current behavior are clear before history; -- canonical domain terms are defined and used consistently; -- actors, rules, outcomes, boundaries, exceptions, and delivery are visible; -- examples are recognizable domain scenarios; -- technical details do not leak into the explanation; -- planned, partial, absent, retired, and unknown behavior cannot be mistaken - for available behavior; -- links provide optional depth without being required to understand the main - answer. - -## Engineering view - -Evaluate as an engineer or operator: - -- product meaning is linked rather than re-invented; -- ownership, entrypoints, flow, contracts, state, failures, operations, and - verification can be located; -- terminology and boundaries are precise; -- implementation detail is proportional to maintenance needs; -- evidence and revision scope are clear. - -## Decision, reference, and uncertainty views - -- a decision explains the durable choice, rationale, consequences, and - lineage without ceremonial padding; -- a reference distinguishes external fact from project choice; -- an uncertainty states the live question, impact, known facts, and required - authority or evidence. - -Return `passed`, `failed`, `uncertain`, or `blocked` per check. Identify the -smallest wording or structural correction, but do not silently change facts. diff --git a/.agents/skills/verify-project-work/SKILL.md b/.agents/skills/verify-project-work/SKILL.md deleted file mode 100644 index fdf6924..0000000 --- a/.agents/skills/verify-project-work/SKILL.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -name: verify-project-work -description: Verify a change against its contract and its real production path before anyone calls it done. Use when claiming work complete, before promoting what it established into knowledge, or when auditing an implementation against the framing that was approved. ---- - -# Verify Project Work - -A green build proves the build is green. Completion is a claim about what the -product now does, and it holds only because someone looked. - -The tool refuses an incomplete accounting on its own: a stale receipt, an open -issue, an unapproved framing, a dirty checkout, an unaccounted decision. It cannot -tell reading from recording, a check that proves something from one that merely -passes, or a criterion nobody exercised from one that failed. Those are yours, -and they are what this skill is for. - -Read [the completion gate](references/completion-gate.md) when a refusal names a -requirement you have not met, or when deciding what a partial closure must say. - -## Read what the review stage names - -`wfctl work context <id> --stage review` lists what must be accounted for, and -`wfctl work review file` records each one at its current hash. - -A receipt proves accounting. Comprehension has no receipt, which is why reading -is the step and recording is the residue. Read each file to its end: a long issue -tail is where deferred work gets written down, and it is the part a skim reaches -last. Mark a supporting artifact `irrelevant` only when you can say what makes it -irrelevant to this result. - -A receipt binds to the bytes it was taken over, so any file the review itself -edits comes back as changed-after-review and needs reading again. Expect that of -`change.md` in particular: the review is what changes it. - -## Review the contract - -Take each thing that was asked for and find three things: the production -behaviour that delivers it, the evidence that it does, and the path a person -using or operating the product reaches it by. - -Expect the gaps to be quiet. A requirement nothing implements. A behaviour that -stops halfway. Work that arrived unasked. An implementation that looks present -and does the opposite of what was agreed. - -Expected values come from the contract or an independent authority. An expected -value read off the implementation confirms the implementation to itself. - -## Review the engineering - -Invoke Graphify-first analysis, then open the real diff and the production path -in every bound code root: source, callers, boundaries, state, errors, consumers. -Judge project standards, architecture, security, operations and maintainability -on their own, whether or not the spec was followed. Run the focused behaviour -checks and the broader test, build, type, lint and runtime checks. - -Hunt what a passing suite hides — a disabled path, a placeholder, a mock standing -in for the thing, a fixture doing the work, temporary compatibility code, an -unhandled branch, work quietly deferred. `evidence-first` says what each of those -proves and what it does not; here the point is that a review which never went -looking will not find one. - -Ask before you commit. Closure requires a clean checkout whose recorded commit -contains the reviewed implementation, and the gate cannot see whether anyone -agreed to the commit that made it clean. - -For project-only work, verify decisions, knowledge and links, and let code -evidence stay absent rather than invented. - -## Write what the project now knows - -Decide whether verified durable truth changed, and write the pages while the -understanding is fresh. Route product behaviour and engineering realization -through their separate curation skills and run the two-axis quality gate — but -write each page under the bundle's `promotion/` directory, at the exact path it -will occupy inside `knowledge/` — spelled with or without a leading `knowledge/`, -which name the same page. Nothing enters the corpus here. - -A concept promoted from a bundle in a project with no reconstructed baseline has -the same shape as one established by whole-project reading and a far narrower -footing: it came from whatever this task happened to touch. Say so in its -`maintainer-decision` source — name the bundle, state that no reconstruction has -covered this subject — so a later baseline re-derives it instead of trusting it. -Draft it anyway. Knowledge grown from real work beats none, and the shortcut -costs something only while it stays unwritten. - -Account for every answer the maintainer gave with `wfctl work decisions <id>`. A -resolved Wayfinder map is the checklist the accounting is held against, and the -promotion gate holds until every answer has a home. - -Then record what is waiting: - -```sh -wfctl work promotion <id> # from what is on disk -wfctl work promotion <id> --none "<why>" # nothing the project says changed -``` - -## Close it yourself - -Closure asks whether the work matches the framing that was approved, and every -part of that answer is in the record already: verified criteria, passed receipts, -pinned revisions, terminal issues. Do not put it to the maintainer. One night -spent waiting on that question cost seven hours and fifty-four minutes and two of -four approved bundles. - -Finish in this order, because each step invalidates the one before it. Every -semantic edit to `change.md` first. Then `wfctl work checkpoint <id> --stage -review`, before the final hash receipt — a checkpoint edit changes the file's -hash, so a receipt taken ahead of it is stale on arrival. Then re-read -`change.md` and everything else the review changed, and refresh those receipts. -Then: - -```sh -wfctl work verify <id> -wfctl work close <id> --outcome completed|partial|abandoned -``` - -Use the honest outcome. `partial` and `abandoned` are results, and a `completed` -that had to be argued for is the one worth doubting. - -One refusal here is a real question rather than an errand: delivery no longer -matches the approved framing, because the acceptance criteria were reworded since -they agreed to them, or work left the route as a dropped issue. That is the one -thing at the end they alone can settle. Render it with `wfctl work ask <id> ---stage completion` and record their answer with `wfctl work approve <id> --stage -completion --attested "<what they said>"`. - -## Then ask the one question that is theirs - -A closed bundle holding pages waits in the promotion queue rather than the -archive, and the pages wait with it. - -```sh -wfctl work ask <id> --stage promotion -``` - -That packet is the pages themselves, in full, saying of each whether it replaces -something the project already claims. It is the decision that compounds: a -completion receipt is read by an auditor once, and a page is read first by every -session that touches this part of the project. - -The render is only as honest as what it reads. A page that says nothing is shown -as empty, and a draft still carrying its template's words is shown as written. -Repair the page rather than the packet. - -Their word writes it: - -```sh -wfctl work promote <id> --by human:<maintainer-id> --attested "<what they said>" \ - --session "<where they said it>" -``` - -That copies every page into `knowledge/`, validates them, and archives the -bundle. If validation refuses, nothing is written and the bundle stays in the -queue — so fix the page and ask again rather than leaving the corpus half-taught. diff --git a/.agents/skills/verify-project-work/agents/openai.yaml b/.agents/skills/verify-project-work/agents/openai.yaml deleted file mode 100644 index 548e7dc..0000000 --- a/.agents/skills/verify-project-work/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Verify Project Work" - short_description: "Verify every bound source against one spec" - default_prompt: "Use $verify-project-work to verify project-only knowledge work or every bound leaf checkout against the central spec and recorded evidence." diff --git a/.agents/skills/verify-project-work/references/completion-gate.md b/.agents/skills/verify-project-work/references/completion-gate.md deleted file mode 100644 index 9ec1da5..0000000 --- a/.agents/skills/verify-project-work/references/completion-gate.md +++ /dev/null @@ -1,52 +0,0 @@ -# Completion gate - -A completed record requires: - -- every plan and acceptance checkbox resolved; -- the change and every relevant issue have a current structured checkpoint; -- every stable acceptance ID marked verified and paired with passed evidence; -- every bundle file except the review ledger accounted for at its current hash; -- no unseen, changed-after-review, or invalid bundle file; -- every work issue completed or explicitly dropped, with no active claim; -- every acceptance ID covered by a non-dropped delivery issue when issues are used; -- no issue dependency cycle or completed issue with an unresolved blocker; -- any retained Wayfinder map resolved, with no remaining fog; -- at least one relevant Graphify query recorded for code-scoped work; -- `acceptance_reviewed: true`; -- `implementation_reviewed: true` for code-scoped work, or - `knowledge_reviewed: true` for project-only work; -- `maintainer_review.framing.status: approved` with a human actor and timestamp; -- `knowledge_promotion.status: pending` with the pages drafted under the bundle's - `promotion/` directory, `applied` with validated concept paths, or `not-needed` - with a concrete reason; -- delivery that still matches the approved framing. Where the acceptance criteria - have been reworded since the approval, or an issue was dropped from the route, - closure additionally requires `maintainer_review.completion.status: approved` — - it is the one case at the end where what was approved is not what was built; -- one or more fresh checks with commands and outcomes; -- `verification.result: passed`; -- no unresolved item without an explicit accepted disposition; -- a deviations section that says `None` or names every remaining gap. -- every bound source checkout clean so each recorded commit actually contains - its verified implementation; -- a matching revision, worktree ID, and checks receipt for every bound - repository. Single-leaf work may use the top-level verification fields; - multi-repository work uses `verification.repositories`. - -Update semantic records first, refresh the owning checkpoint last, then re-read -the changed record and record its final file receipt. A checkpoint edit changes -the file hash; recording the receipt before the checkpoint would immediately -make that receipt stale. - -Nothing here needs the maintainer. Every item is something the record either -carries or does not, which is why closure is the agent's: a person asked to -confirm this list is signing arithmetic they cannot check better than the tool. -What is theirs is the framing before the work and the pages after it. - -The CLI validates the bundle graph, file hashes, record structure, exact source -bindings, and any promoted concept files. It cannot prove that a conversation -occurred, that semantic evidence is correct, or that no material claim was -omitted. The agent must record a maintainer approval only after an explicit -decision, and the maintainer remains responsible for that decision. - -If a requirement is intentionally dropped, update scope and record who accepted the change. Do not merely check it off. diff --git a/.claude/rules/evidence-first.md b/.claude/rules/evidence-first.md deleted file mode 100644 index e7afc11..0000000 --- a/.claude/rules/evidence-first.md +++ /dev/null @@ -1,67 +0,0 @@ -# Evidence-first reasoning - -Treat claims, plans, existing documents, and prior agent output as unverified until checked. - -For any task that depends on understanding, locating, changing, debugging, -reviewing, or verifying source code: - -1. Invoke `analyze-with-graphify` before reading or searching source code, even - when the maintainer does not mention Graphify. -2. Require it to inspect the current session skill catalog and invoke the - official native `graphify` skill. -3. Stop and tell the maintainer when either the Graphify CLI or native session - skill is unavailable. Offer the supported installation and session-restart path. -4. Use text search only after graph traversal, as a supplementary precision tool. -5. Inspect the actual source reached through the graph and record the query, - pinned revision, paths, symbols, and checks in the active change or curation - record. Graphify output is navigation, not authority. - -Do not use Graphify as the primary analyzer for raw or curated Markdown. -Use QMD for Markdown retrieval and the generated knowledge graph for explicit -relationship expansion. Use the generated claim ledger only for explicit -intake/reconstruction lineage. Neither graph is evidence; read selected files -directly. Before -knowledge-dependent work, require the official native `qmd` skill in the -current session and invoke it. An on-disk skill installed after session start -does not count; stop and request installation or a session restart instead of -inventing a partial QMD procedure. Raw coverage comes from Git-frozen intake -sources and explicit full-file review. Existing-project coverage comes from -exact clean source revisions, Graphify traversal, direct source and test -inspection, Git history review, repository dossiers, cross-repository -reconciliation, and maintainer adjudication. Curated trust comes from OKF -metadata, authoritative provenance, and current verification. QMD rank, -snippets, and its index are never authority. - -Assume no source is in good condition. Documents may reference deleted paths, -specifications may have been rewritten without supersession, notes meant to be -temporary may be the only written intent, and history may record file moves -rather than decisions. Judge each source as it actually is in this project, not -as its kind is supposed to be. - -When a source turns out to be unreliable, that narrows what it can establish and -widens nothing else. Do not answer it by electing a replacement authority: every -source stays a witness, and reconciling them is the work. Keep reading the -degraded source for terminology, chronology, and leads. Where it contradicts -itself over time, reconcile by chronology rather than choosing a version. - -When no source can establish something, say so and record it as unknown. An -honest unknown is a result; a plausible answer assembled from the least-bad -source available is a guess the maintainer can no longer see through. - -Never establish that work is progressing by checking that a process exists, and -never identify a process by matching its name: the pattern matches the shell -doing the checking, so a command that is waiting on itself reports as running. -Ask the data instead — the counters, records, or files the job writes. When a -background command is reported silent, that is a prompt to check, not a finding. -Compare consumed CPU time against elapsed time, read what the job has written, -and only then decide. Do not agree that something is broken because it was -reported, and do not restart healthy work; that costs more than waiting. - -Do not turn mocks, fixtures, fakes, stories, showcase pages, benchmark -harnesses, placeholders, disabled checks, or partial wiring into production -completion claims. Behavior that exists only inside a demonstration surface is -not delivered, and behavior whose only caller is a test is implemented but not -verified — a green suite proves the test passed. Which of the two holds is a -question about what reaches the code, so it takes graph traversal and a recorded -query rather than a file listing. Name missing evidence and unfinished work -directly. diff --git a/.claude/rules/execution-continuity.md b/.claude/rules/execution-continuity.md deleted file mode 100644 index 3cd2559..0000000 --- a/.claude/rules/execution-continuity.md +++ /dev/null @@ -1,83 +0,0 @@ -# Execution continuity - -This applies while executing accepted work: a claimed issue, an approved scope, -a frozen frontier. It does not apply while shaping, specifying, or grilling a -decision with the maintainer. There the question count should be high, one -focused question at a time is correct, and stopping to ask is the work itself. - -Ending a turn is an act, not a default. The whole corpus says how to ask the -maintainer and nothing says when not to, so an unowned pause reads as safe when -it is only cheap. While accepted work remains, hold three lines. - -**Find it before asking it.** Locate the question in the accepted material — -the specification, the issue, the discovery ledger, the parent bundle's -decisions, the case record — and quote the line that answers it. A question the -material already answers is not a question. Absence of a quote is not permission -to ask; it is evidence the search was not done. - -**A report is not the finish line.** Completion is defined by the issue's -acceptance criteria and proven by its terminal status. A finished plan item, a -written summary, a passing subtask, and a refreshed checkpoint are progress. - -**Check that a stop would lose nothing, rather than believing it.** `wfctl -resumable` answers that from the repository: a checkpoint describing a record -that has since changed, an open record that never had one, and work on disk no -checkpoint describes and no commit preserves are three different losses, and the -last is the one a basis digest structurally cannot see. A non-zero exit is not a -finding to report onward — refresh the checkpoint or commit, then end. The -maintainer asking you to wrap up is the failure this replaces. - -**End a turn only when you are waiting on the maintainer.** Ending one hands -control to them, so the test is what you are waiting for and not what you -wrote. Announcing a next action and stopping is the visible half of this; the -larger half announces nothing — "the work continues by itself", "the rest can -wait for the next boundary", a status report that names no blocker — and parks -just as completely, because nothing continues once the turn is over. If you are -not waiting on the maintainer, take the next action you can take alone. - -**Finishing a unit is not finishing.** Completing an issue releases its claim, -so the bundle is left holding ready issues that nobody has claimed — which is -what every long run looks like between units, and the moment a turn is most -likely to end on "next I will do X" and then not. There is no boundary there. -The next unit is available work, and available work is yours. - -**Say why you are stopping, in the record, when you stop.** Prose is not state: -a turn that explains itself and ends has explained itself to nobody, because the -explanation goes with the session. Two sentences end a turn, and they are -different sentences. - -- The maintainer is what the work is missing. Record a blocker on the owning - checkpoint — `--status blocked --blocker "<what you need from them>"` — which - puts the work in their queue and takes it off yours. -- Nothing is missing except this session: the context is spent, or the next unit - will not fit in what is left of it. Record `--handoff "<why this session stops - here>"`, which tells the next session and asks them nothing. It is cleared by - the next checkpoint, so it explains one stop rather than every stop after it. - -A blocker for the second case is a lie that costs them a turn, and silence for -either is what the workflow returns you to. - -**Never wait for a background command by spinning.** A command that outruns its -foreground limit is moved to the background and announces its own completion; -waiting for it is not your job and doing so costs more than it saves. A loop -that polls a file without pausing consumes a whole core, and it takes that core -from the build it is waiting for — a Rust suite was watched this way twice in -one session, for five hundred seconds each, against a compile competing for the -same processor, and the second watch timed out having learned nothing. If you -genuinely must wait on something the host does not announce, pause between -checks rather than spinning, and prefer doing unrelated accepted work. - -**A discovery is not a stop.** Material that contradicts what was accepted is an -entry in the discovery ledger, and the work continues. Stop only when the -decision is irreversible beyond the current bundle, or when no unblocked work -remains — repeated identical failure, unavailable authority, a blocked source. -Discomfort and uncertainty are not on that list. Record the uncertainty as -uncertainty and keep going. - -Questions that genuinely need the maintainer accumulate and are presented at the -frontier, the batch boundary, or review. Adjudication is a recorded claim -awaiting authority, not a halt: continue with every unit that does not depend on -the answer. - -Do not answer this rule with agreement, restate it, or confirm that it is -correct. Apply it and take the next action. diff --git a/.claude/rules/maintainer-review.md b/.claude/rules/maintainer-review.md deleted file mode 100644 index ee9f2d1..0000000 --- a/.claude/rules/maintainer-review.md +++ /dev/null @@ -1,327 +0,0 @@ -# Maintainer review - -OKF records provenance, trust, and lifecycle; it does not provide an approval -process. Follow the project review protocol in `PROJECT_WORKFLOW.md`. - -Require an explicit maintainer decision before: - -- choosing a workflow route when significance is ambiguous; -- starting raw processing, whole-project reconstruction, durable external - research, semantic curation, or broad direction shaping when the maintainer - did not already request that outcome; -- implementing a significant spec whose outcome, scope, exclusions, acceptance - criteria, or material decisions have not already been explicitly accepted; -- writing anything into `knowledge/`, which is the project speaking about itself; -- selecting current truth when evidence cannot resolve chronology or authority; -- closing work whose delivery no longer matches the framing they approved; -- recording `verified` by a `human:<id>` actor. - -Do not request review for freezing a clean Git raw scope, file accounting, QMD -refresh, index or log maintenance, or source-backed drafts that keep their -trust state honest. Raw intake itself never counts as evidence. - -Reading is never a decision. Accounting for what each bound repository declares -about itself, running Graphify in every bound root, checking curated knowledge -before settling meaning — a gate refusing until those are done is telling the -agent to go and do them, not handing the agent a question. Nothing about them -reaches the maintainer: not as a request, not as a status line, and not as an -apology for the delay. The one thing that does reach them is a product -consequence found while reading — a rule in one repository that makes the -proposed route impossible there is a decision, and it is put to them as what the -product must do instead, never as the file it was found in. - -Record approve, correct, or defer. Silence is not approval. - -## Write for someone who was not watching - -The maintainer did not see the tool calls, the gate that refused, or the file -you fixed on the way. They hold no identifier you generated and no count from -your last turn. Every message is read cold, by a person deciding about their -product, and one that catches them up first buries what it came to say. - -A message carries three things in this order, and stops: - -1. **What is true now** that was not true before. One or two lines. -2. **What you need from them**, if anything. One question, one recommendation. -3. **What happens next without them.** One line. - -Everything else goes in the record, which already has a place for each kind of -it: the discovery ledger for what you learned, the checkpoint for where the work -stands, review receipts for what you read, the blocker for what you are waiting -on. Sending it instead is not thoroughness. It moves the reading onto the person -least able to do it, and the record still ends up empty. - -The message is finished when cutting any remaining sentence would change what -the maintainer does next. Cut one and check. If nothing changes, it was written -for you. - -A structured document is the common way to fail this, because it looks like -care. Four hundred reports written while this rule was already in force: -sixty-three per cent carried a table and twelve per cent ended in a question. A -table compares things the reader already cares about. Reached for before there -is a question, it is a log with borders. Write the three lines first, and add a -table only when the answer turns on a comparison. - -**Sentences.** Use the active voice. Give one idea per sentence and keep it to -twenty words. Use simple past, present, and future. Use the same word for the -same thing every time; a synonym reads as a second thing. Drop idioms, slang, -and internal vocabulary. Keep paragraphs to six sentences. - -**Keep technical items exact.** A path, a symbol, a version, a price, or a count -is reproduced character for character or left out. Never paraphrase one into -approximate prose: "about five thousand" and "the pricing worker" cannot be -checked, and being unable to check it is the cost the shortening was supposed to -avoid. What decides whether it appears at all is the reader test below. - -## Ask one thing, and make it cheap to answer - -Give the answer a shape they can hit. Name the options, or say what a bare yes -changes. A question with no stub gets answered with a paragraph you then have to -interpret, which is a second decision you made on their behalf. - -Say plainly that "I do not know" is an answer you can record. A recorded gap is -worth more than a preference guessed from a polite reply and then treated as -settled. - -**Write their reply before you send the message.** Draft the answer you expect, -in their voice, one line. If nothing fits — if the only reply available is -"which one?" or "what specifically?" — then what you wrote is a status line and -the question is still missing. - -Saying that something waits is the common way to fail this, and it fails because -it describes your queue rather than their decision. "Three records are ready and -all three are waiting on you" passes every other rule here: it names no count -alone, it can carry a table, it reads as brevity. It still leaves them to work -out what they are being asked. So does a category — "the boundaries", "a decision -about the deck", "your word on the shape" — which names the subject and withholds -the question. What passes is an alternative with its consequence: "delete the -twenty-two explanations, or give the row a note field; the note is my -recommendation, and it costs one migration." - -This is a test rather than a list of phrases to avoid, because the failure is the -same in every language and rewords faster than any list can be kept. Apply it to -each sentence that puts something to them, including a blocker, a status line, -and the last line of a report. - -## Find their answer before asking for it again - -Run `wfctl knowledge decided "<subject>"` before putting anything to them. It -reads the four places an answer lands — a promoted decision page, the bundle that -asked the question, a resolved Wayfinder map, and a capture — and reports what it -finds with the date, their own words, and whether a curated page carries it. On a -bundle, `--record <id>` writes the result into the framing, and the framing gate -holds until it has. - -Their answer is usually not on a page. Twenty-two of twenty-six recorded -decisions in one project had never been promoted, so a search of curated -knowledge truthfully found nothing and read exactly like a question nobody had -answered. Work already delivered counts the same way: a completed issue that -recorded a consequence as theirs to settle has answered the question once -already, and asking again spends their turn on their own bookkeeping. - -Cite the page when there is one and the record when there is not, and say which. -A decision reachable only through an archive is one the corpus has not been -taught, and saying so is what eventually teaches it. - -## Re-establish the subject before asking about it - -A claim is the unit of record. It is not the unit of a question. Recorded -atomically, a claim keeps only what distinguishes it from its neighbors, which -is exactly what a reader who was not there cannot reconstruct: a packet built -straight from one carries a difference with no thing attached to it. "This is -one colour on one side and another on the other side — was that intended?" is a -complete atomic claim and an unanswerable question. - -Before the packet is written, re-establish four things and put them above the -question: - -- **What it is**, named as the product names it, and where a person using or - operating the product meets it. -- **Where it came from** — which sources say this, and when they said it. -- **What turns on it** — the flow it sits in and what changes downstream if the - answer goes either way. -- **What is true right now.** Go back to the pinned source and the current - implementation and check. The candidate was extracted at reading time, before - later findings, and other candidates recorded since may already contradict or - settle it. A packet that repeats a days-old record without re-reading is - asking the maintainer to adjudicate the agent's memory. - -Ask about the subject and list its atomic claims underneath, rather than asking -about each claim. Several claims about one capability are one question; one -claim touching several capabilities is still one question, about the one thing -that has to be decided. - -Translate rather than cite. A term that exists only in the implementation gets -replaced by what it does for the product, with the original in parentheses at -most once. A subject that cannot be described at all without file paths or -symbol names is a finding to record — the capability has no product-level -description yet — and never a licence to send the identifiers instead. - -## The line, and how to tell which side you are on - -| | Written for the record | Written for the maintainer | -| --- | --- | --- | -| Reader | The next agent, the compiler, an auditor | A person deciding about their product | -| Addresses | **Required.** A claim without its pointer is unverifiable | **None.** An address is something to look up, not something to read | -| Vocabulary | Whatever is exact | Whatever the product itself uses | -| Failure | A missing pointer | A sentence that cannot be answered without opening something | - -Apply one test to every sentence before it reaches the maintainer: - -> Would they have to look something up to understand this? - -If yes, it is an address, whatever form it takes — a file, a symbol, a record id, -a ledger code, a commit, a section number, a milestone or tier code, a schema -value printed as a category, or a term that exists only inside the -implementation. It is a test rather than a list because it reads the reader, not -the shape of the string or the language it is written in. - -Two failure modes, and over-correcting the first produces the second: - -- **Addressed prose.** "The check in the catalog store was narrowed because the - gate validates on publish." Every noun is something to look up. -- **Emptied prose.** "A check was narrowed for a reason that turned out to be - wrong." Nothing to look up and nothing to decide on either. - -What is wanted is neither: say what the thing does for the product, and say it -with enough substance to be judged. "The client stopped checking anything but the -shape of a record, on the belief that publishing would catch the rest — and -publishing does not." The pointer to the file stays in the record, and is brought -out when the maintainer asks to audit rather than to decide. - -An address is never deleted to satisfy this. It moves. A finding whose evidence -was dropped to keep a sentence clean has been made worse, not better. - -Where a packet can be generated from records rather than composed, generate it. -A renderer that never reads an address cannot print one, which is worth more than -care taken one message at a time. Where it must be composed — most places — the -test above is the whole rule, and it is the agent's to apply. - -One decision per packet. Several stacked in one message read as a status report -and get answered as none of them. - -Report waiting decisions by name and subject, or do not report them. Never by -count. "Eleven cases are gate-clean and none can close without your decision" -is accurate, reads as brevity, and works as concealment: it says nothing about -what the eleven contain, and the longer the queue the more it hides. Name each -one and what it turns on, one at a time; if that is too long for a single -message, that is a fact about the backlog, not a reason to compress it into a -number. - -Confirming a written record is the exception, and it is one decision rather than -many. When the material already carries the maintainer's own dated decisions, -ask whether they still stand — once for the record, with named exceptions — -instead of reopening each from scratch. Recovering intent that was never written -and confirming intent that was are different questions, and asking the harder -one when the easier one applies is how a body of accepted work becomes an -unanswered queue. - -## Where the two gates are, and why closure is not one - -A maintainer decides two things about a piece of work. What it is, before it -starts. What the project says about itself afterwards. Everything between those -is the agent's, including the moment the work finishes. - -**Put the framing decision before implementation.** `wfctl work issue claim` -refuses a delivery issue whose framing is unapproved, which is the moment to -present it: the bundle has been read, nothing is in flight, and the maintainer's -absence blocks nothing yet. Approving edits the change record, so refresh its -checkpoint and re-read it before claiming. - -**Closure is arithmetic, so close it.** Whether the acceptance criteria are met, -the receipts carry evidence, every issue is terminal and the revisions are pinned -is what the completion gate itself checks. A maintainer asked to confirm that is -being asked to sign a sum they cannot check better than the tool, and the cost is -not theoretical: four bundles were framed and approved for one unattended night, -two were delivered in sixty-two minutes and stopped at that gate, the other two -were never started, and seven hours and fifty-four minutes passed before anyone -could say the word. Nothing about those two bundles was in doubt. - -**Closure returns to them when delivery drifted from the framing.** That is the -one case at the end where something is genuinely undecided, because what was -approved is not what was built. Two things raise it, both observable: the -acceptance criteria have been reworded, added to or cut since the approval, or -work left the route as a dropped issue. The tool names which, and the completion -approval it then asks for is the same command it always was — now the exception -rather than the toll. - -**Promotion is the gate that compounds.** Approving a completion writes a receipt -an auditor may read once. Approving a page writes what every future session reads -first and what the next framing is aligned against. Draft the pages under the -bundle's `promotion/` directory before closing, record them with `wfctl work -promotion <id>`, and put them to the maintainer with `wfctl work ask <id> --stage -promotion`, which shows the pages themselves rather than a list of paths. Their -word, through `wfctl work promote <id>`, is what writes them into `knowledge/`. - -A bundle closed with pages waiting sits in the promotion queue instead of the -archive, and nothing running is held by it — the code shipped, the issues are -terminal. What it does hold is the next framing approval in the same Area, on the -ground that aligning new work against knowledge already known to be behind is the -alignment telling them something the project has stopped believing. - -The packet ends by telling them a wrong page gets rewritten rather than argued -for, and that is a working sentence: rewrite the draft where it sits in the -queue, reseal it, refresh its review receipt, and run `wfctl work promotion <id>` -again. Their answer is never lost by correcting what it was about. - -Approval and permission to start are different decisions and the record holds -them separately. A maintainer who approves a framing and says the work is not to -begin yet — including one approving only so the bundle stops cluttering their -queue — is parking it: `wfctl work approve ... --park "<why>"`, or `wfctl work -park` afterwards. A parked bundle refuses every delivery claim before any other -gate is even read. Only `wfctl work release --attested "<their words>"` starts -it, and a release is never inferred: not from a truthful answer to an unrelated -question, and not from the condition that held it having cleared. The last time -one was inferred, six commits landed in three source repositories. - -Render every gate with `wfctl work ask <id> [--stage promotion|completion]` -rather than composing one. A framing carries what gets done, what deliberately -does not, what will make it finished, and in what order. A promotion carries the -pages themselves, in full, and says of each whether it replaces something the -project already claims. A completion — asked only where delivery drifted — -carries what the work does now, what it still does not do, what closing it takes -on, and what the project now says that it did not. Nothing else from a record -written for an agent reaches any of them. A section still holding the shipped -template's own words is reported as unwritten rather than read out as scope. A -render is only as honest as the record behind it: repair the record rather than -the packet, because a packet edited by hand is composed again. - -Record a framing with `wfctl work approve <id> --stage framing --by -human:<maintainer-id>`, and a promotion with `wfctl work promote <id> --by -human:<maintainer-id>`, which writes the pages in the same act as the receipt. -Both write the `maintainer_review` entry and the durable approval record the -gates check. A framing approval also digests the acceptance criteria it settled, -which is what later tells a reworded contract from the one they agreed to. - -Pass `--attested "<their answer, word for word>" --session "<where they said -it>"`. That is the ordinary path, because the ordinary case is a maintainer who -already answered in conversation, and sending them to a second terminal to -retype a generated bundle id, a stage name and their own identity relocates the -same answer to a less convenient channel while recording no more than the -attestation does. A typed confirmation and an out-of-band `--token` matching -`WFCTL_APPROVAL_TOKEN` both remain, unequal and recorded as such: they prove a -command ran outside your own writing, which is a stronger record and the -maintainer's to ask for, never your default. Never hand-write -`maintainer_review.status`, `by`, `at`, `method`, or `receipt`: a hand-written -receipt fails verification. Existing explicit maintainer instructions may -satisfy the framing decision, but still record it through the command; do not -ask for the same decision twice. Reopen the gate when the approved framing -changes materially — and where the acceptance criteria are what changed, the tool -reopens it for you at closure rather than trusting anyone to notice. - -Approving edits the change record, so re-read it, refresh its review receipt, -and refresh the checkpoint afterwards. - -A checkpoint blocker is the one sentence written for the maintainer rather than -for the next agent, and the only one that reaches them across a session -boundary. That makes a stale blocker worse than a missing one: a missing blocker -makes the next session look, and a stale blocker makes it act — reopening a -decision already made, or recommending an option since proved impossible. When -an answer, a discovery, or a change removes what a record was waiting for, -rewrite its blocker in the same turn. Nothing validates blocker text against the -record it hangs on; only the person changing the record can keep it true. - -After every material maintainer turn, update the spec's mutable current state -and append the decision/discussion ledger before continuing. Preserve proposed, -approved, rejected, deferred, and superseded outcomes without copying the -conversation transcript. diff --git a/.claude/rules/project-work-bundles.md b/.claude/rules/project-work-bundles.md deleted file mode 100644 index 3a8cf30..0000000 --- a/.claude/rules/project-work-bundles.md +++ /dev/null @@ -1,57 +0,0 @@ -# Central project work bundles - -The agent owns routine `wfctl work` commands and structured records. The -maintainer supplies intent, corrections, authority, and explicit review -decisions; do not make them operate the tracker. - -For significant work, create exactly one bundle under -`changes/active/<change-id>/` before extended discussion. `change.md` is the -parent contract, `map.md` is optional Wayfinder lineage, `issues/` contains -bounded work, `artifacts/` contains referenced support, and `review.md` records -full-file accounting. A leaf stores only an ignored binding pointer. - -After every material maintainer turn or agent investigation cycle, preserve any -new information whose loss could cause repeated material investigation, a -different choice, misunderstanding, or unsafe action. Append it to the owning -change or issue `Discovery ledger` with observation, evidence, implication, -scope, and disposition. The ledger is not a fixed taxonomy or activity log. -Then update current state, decisions, acceptance, progress, and evidence, and -run `wfctl work checkpoint` last. A stale checkpoint blocks later gates. - -After interruption, compaction, or a clean-session start, run `wfctl work -context --stage resume` without an ID. Auto-select only when exactly one active -record is bound here; when several exist, inspect `wfctl work status` and ask -the maintainer rather than guessing. Inspect the reported checkpoint, read -every required file and discovery entry completely, verify the exact claim and -code roots, and resume from the bundle rather than conversation memory. - -Use `changes/inbox/` only for pending captures that have no active or curated -owner. Never duplicate active progress there. Resolve each capture to existing -destinations or discard it with a reason so the inbox remains a real queue. - -Before claiming an issue, record a current review receipt for every required -context file. Claim from the exact bound leaf before code work. Never infer a -worktree from repository name, branch, sibling paths, or bundle location. - -A bundle started from the centre without naming a leaf binds no source -repository, and delivery has nowhere to happen. Give it one from that -repository's own checkout with `wfctl work bind <id>`; `wfctl work rebind` moves -a binding the record already carries and is the wrong verb here. Neither is a -maintainer decision. - -Wayfinder is deliberate and planning-only. It resolves precise question issues -and fog into a reviewed specification before any delivery issue or product-code -implementation begins. - -Before completed closure, enumerate the entire bundle with `wfctl work context ---stage review`, re-read every file, refresh stale receipts, reconcile every -stable acceptance ID against direct evidence, and draft the curated pages this -work changes under the bundle's `promotion/` directory. Then record them with -`wfctl work promotion <id>` and close. Closure needs no maintainer: it asks only -whether the work matches the framing they already approved, and the gates answer -that. The pages are what needs them, and they are asked after the bundle closes. - -A capture is not automatically a chore. When it holds something only the -maintainer can settle, create it with `--awaits maintainer` so it appears in -their queue rather than in agent triage, and say so when reporting. A question -filed as triage is a question nobody knows they were asked. diff --git a/.claude/rules/project-work-lifecycle.md b/.claude/rules/project-work-lifecycle.md deleted file mode 100644 index 5849c8b..0000000 --- a/.claude/rules/project-work-lifecycle.md +++ /dev/null @@ -1,53 +0,0 @@ -# Leaf project work lifecycle - -This checkout is an implementation surface. Its configured knowledge -repository owns significant-work bundles and current project knowledge. - -1. Route the request with `manage-project-work`. -2. For significant work, create or reuse one central bundle and run `wfctl work - status` plus the stage-specific `wfctl work context`. -3. Use `specify-project-change` for a bounded contract or - `shape-project-direction` only for explicit Wayfinder. -4. Align the contract with curated knowledge and analyze source Graphify-first. - An empty or uncovering corpus is a supported state in an existing project: - report that nothing curated covers this work and what the alignment rested on - instead, rather than reporting no conflicts. Recommend a reconstruction when - the gap is material; never treat one as a precondition for the work. -5. Record explicit framing approval before code edits. -6. Use `split-project-change` for multi-session work. It creates central issues, - never leaf-local tickets. -7. Use `implement-work-item` for one frontier issue. Read every required file, - record current receipts, and claim the issue from this exact checkout before - implementation. -8. After material discussion or investigation, preserve consequential new - understanding in the owning record's broad `Discovery ledger`, update the - affected semantic state, then refresh its structured checkpoint last. Never - copy active progress into the inbox. -9. Verify behavior through direct production-path inspection and fresh checks. -10. Preserve implementation in the exact clean Git commit only with normal - maintainer authorization. -11. Run `verify-project-work` across the whole bundle and every bound source - revision, draft the curated pages under the bundle's `promotion/` directory, - record them with `wfctl work promotion <id>`, and close. None of that needs - the maintainer: closure is what the gates already prove, and they prove it - better than a person reading a summary of it at midnight. -12. Put the pages to them with `wfctl work ask <id> --stage promotion` and write - them with `wfctl work promote <id>` once they answer. Until then the closed - bundle waits in the promotion queue rather than the archive, and the pages - wait with it. `knowledge/` never holds a page nobody approved, and the corpus - is never left invalid so that progress can look further along than it is. - A page they send back is rewritten where it sits, resealed, its receipt - refreshed, and recorded again with `wfctl work promotion <id>`. - -On a clean session or resume, run `wfctl work context --stage resume` without an -ID. It may auto-select only one bound active record; multiple records require a -maintainer choice. Read its complete required-file set and discovery ledgers, -then verify status. Before code edits, after any directory or branch change, -after compaction, and before verification, re-run work status. Every reported -code root is an exact workspace. The returned bundle/spec path is for records -only. A worktree is not interchangeable with another checkout of the same -repository. - -Do not claim completion with unseen or stale bundle files, open issues or -claims, uncovered acceptance IDs, unresolved Wayfinder fog, dirty checkouts, or -missing evidence. Use partial or abandoned outcomes instead of relabeling gaps. diff --git a/.claude/rules/workflow-routing.md b/.claude/rules/workflow-routing.md deleted file mode 100644 index bfbf0aa..0000000 --- a/.claude/rules/workflow-routing.md +++ /dev/null @@ -1,28 +0,0 @@ -# Workflow routing - -Classify work before changing product or implementation state. Creating a -`shaping` record is safe recordkeeping, not implementation. - -A read-only question about the project, its current capabilities, or one -product direction is not implementation work. Invoke -`explore-project-knowledge` against the configured knowledge repository and -answer progressively. Do not create a shaping record unless the conversation -turns into a proposed decision or change. - -Use the full workflow when work may change observable behavior, domain meaning, interfaces, data or control flow, persistent state, security, reliability, architecture, operational behavior, or coordination across components or repositories. - -Skip the full workflow only when the change is clearly local and preserves behavior and contracts, such as presentation-only polish, copy edits, formatting, or a mechanical correction with no design choice. - -If classification is uncertain, explain the possible impact and ask the maintainer whether to use the full workflow. Recommend one answer. If the maintainer declines, proceed lightly and offer a pending capture only when a reusable finding has no existing owner. Use `wfctl work capture add`; never duplicate active work in `changes/inbox/` or present the capture as authoritative history. - -Never use task size alone as the classifier. A one-line contract change can be significant; a large mechanical rewrite can be lightweight. - -Once work is classified as significant, create its central change bundle before -extended discussion so requirements, issues, decisions, and progress survive -session compaction. - -When a consequential initiative is too uncertain to define acceptance criteria -without guessing across several dependent choices, recommend -`shape-project-direction`. Start Wayfinder only after maintainer agreement, -keep its map and issues in the same central bundle, and do not edit code until -the resolved route has been synthesized into a bounded specification. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 653a4e4..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "[ -f \"$CLAUDE_PROJECT_DIR/.workflow/runtime/guard-background-bash.mjs\" ] && node \"$CLAUDE_PROJECT_DIR/.workflow/runtime/guard-background-bash.mjs\" || true" - } - ] - } - ], - "Stop": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "[ -f \"$CLAUDE_PROJECT_DIR/.workflow/runtime/guard-stop.mjs\" ] && node \"$CLAUDE_PROJECT_DIR/.workflow/runtime/guard-stop.mjs\" || true" - } - ] - } - ], - "SessionStart": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "wfctl brief --hook" - } - ] - } - ] - } -} diff --git a/.claude/skills/align-project-knowledge/SKILL.md b/.claude/skills/align-project-knowledge/SKILL.md deleted file mode 100644 index ce4382a..0000000 --- a/.claude/skills/align-project-knowledge/SKILL.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -name: align-project-knowledge -description: Align a significant task's shaping spec with current project purpose, Areas, capabilities, flows, architecture, decisions, repository responsibilities, and known uncertainties. Use after the shaping record exists and before choosing a design, approving framing, changing a contract or flow, or making assumptions about why the project behaves as it does. ---- - -# Align Project Knowledge - -Do not design from code and memory alone. Establish the project's current intent before proposing a solution. - -## Procedure - -1. Read `.workflow/config.json` and resolve the configured knowledge repository. -2. Inspect the current session skill catalog and require the official native - `qmd` skill. Invoke it before retrieval. If it is absent, stop and ask to - invoke `setup-workflow-environment` to repair or reinstall the selected - project/user skills, then ask only for the unavoidable agent-session - restart. An on-disk file alone does not prove the running agent loaded it. -3. Run QMD from that knowledge root. Require `qmd status`; if it reports - documents pending embedding, run `qmd embed` before relying on vector or - hybrid retrieval — indexing and embedding are separate, `qmd update` only - marks what needs vectors, and searching without them silently degrades to - lexical BM25 over exactly the material most recently written. If QMD or the - project-local `.qmd/index.yml` is missing, stop and report the broken - workflow environment. -4. Run `wfctl knowledge build --target <knowledge-root>`. Stop alignment if - validation, knowledge-graph compilation, or claim-ledger compilation fails; - do not silently reason over broken navigation. The generated - `.workflow/current/knowledge-graph.json` is disposable knowledge navigation. - `.workflow/current/claim-ledger.json` is disposable explicit candidate - lineage. Neither is authority or an edit target. -5. Start at `knowledge/index.md`, then use `qmd search ... -c knowledge` for - exact terms or a structured `qmd query` with authored `intent:`, `lex:`, - `vec:`, and when useful `hyde:` fields for hybrid retrieval. - If QMD MCP is available, use `query` with - `collections: ["knowledge"]`. Open the returned concepts directly and use - directory indexes for progressive disclosure. Retrieval ranking is not - authority. -6. Expand the QMD candidates through explicit incoming and outgoing edges in - the compiled graph. Follow material typed relationships, Area ownership, - decision lineage, and human-authored links so lexical similarity does not - define the task boundary. -7. Open only concepts relevant to the work, including: - - vision and non-goals, - - the relevant Area index, capabilities, concepts, rules, and flows, - - architectural boundaries, - - current and superseded decisions, - - repository responsibilities, - - recorded uncertainties. -8. Inspect `status`, `generated`, `verified.content_hash`, `stale_after`, and - `sources` before treating a concept as authoritative. `wfctl knowledge - validate` must prove that at least one verification matches the current - material content. -9. Follow links to predecessor decisions and supporting sources when the proposed work depends on them. -10. Compare the proposed behavior with both code evidence and curated intent. -11. Treat only `knowledge/` as the default current-knowledge surface. Do not - consult `raw/` or `intake/` to fill a gap. -12. Ask whether this was already decided: `wfctl knowledge decided "<subject>" - --record <id>`. It reads the four places an answer lands — a promoted - decision page, the bundle that asked the question, a resolved Wayfinder map, - a capture — plus issues already delivered, and writes what it found into this - bundle's alignment, which the framing gate requires. Cite the promoted page - when there is one and the record when there is not, and say which. Search - `changes` with QMD as well when the wording is uncertain: the command ranks - by overlapping terms and finds what shares them, where a semantic query finds - what means the same thing. -13. Record QMD queries, graph-expanded concept paths, directly reviewed - concept paths, constraints, and any conflict in the central change bundle. - -When a bundle already exists, run `wfctl work status <id>` and stage-specific -`wfctl work context <id>` first. Read curated knowledge from its `Knowledge -root`, update only the returned bundle files, and inspect implementation only -from exact `Code roots`. Do not treat the knowledge repository as the -implementation checkout. - -## When there is no baseline yet - -An existing project installed into this workflow starts with an empty or barely -populated `knowledge/`, and that is a supported state, not an error. A -reconstruction is expensive enough that nobody runs one before their first fix, -so most first tasks in a real repository run without one. - -Report absence rather than a clean result. "No conflicts with curated knowledge" -is literally true against an empty corpus and tells the reader nothing, while -reading exactly like a completed check. Record instead that no curated concept -covers this work, that the contract is therefore unaligned by absence rather -than by verification, and what the alignment rested on instead — pinned source, -tests, maintainer statements. The same applies to a populated corpus that simply -has nothing about this Area: coverage is per-subject, not per-repository. - -Recommend a reconstruction when the gap is material and say what it would -establish, then proceed if the maintainer declines. It is a recommendation, and -it never becomes a precondition for doing the work. - -## Recorded drift is work nobody has claimed - -`realization.alignment: drifted` on a curated page means the project accepted an -intent its implementation does not deliver. That row is a faithful record and -nothing more: reconstruction never edits source, so the gap it names outlives -the case that found it. `wfctl brief` reports `corpus.intent-delivery-drift` -with the pages by name, and a body of debt that only ever appears there is the -same as no record at all. - -Read those pages whenever work touches their Area, and treat each one as a -candidate the current task either resolves, widens, or leaves untouched. Say -which, in the alignment record, so the next reader knows the drift was seen -rather than missed. - -Drift becomes work through the ordinary route and never by direct promotion. -Group the drifted pages by the outcome that would close them — several rows are -usually one initiative — and put that outcome to the maintainer as one decision -with three honest answers: shape it now, accept the gap and record the intent as -superseded so the drift disappears truthfully, or defer it with a reason. Only -the first creates a bundle, through `shape-project-direction` when the route is -foggy and `specify-project-change` when it is not. - -Never resolve drift by editing the page to match the code. That erases an -accepted intent to make a check pass, and the record of what the project meant -is the only thing that made the gap visible. - -## Conflicts - -- `raw/` is neither evidence nor current truth. It is an untrusted clue source - used only through `process-raw-intake`. -- A later timestamp does not automatically make a source authoritative. -- `status: stable` is valid only with a matching current content hash; normative - claims additionally require human verification. -- A timestamp without a matching content hash does not prove the current text - was reviewed. -- When sources or code disagree and the correct intent cannot be established, ask the maintainer. -- Preserve unresolved uncertainty explicitly. Do not create a spec that silently selects one interpretation. diff --git a/.claude/skills/align-project-knowledge/agents/openai.yaml b/.claude/skills/align-project-knowledge/agents/openai.yaml deleted file mode 100644 index df0a442..0000000 --- a/.claude/skills/align-project-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Align Project Knowledge" - short_description: "Align shaping with Areas and current truth" - default_prompt: "Use $align-project-knowledge to align this shaping record with relevant Areas, decisions, architecture, and current curated truth." diff --git a/.claude/skills/analyze-with-graphify/SKILL.md b/.claude/skills/analyze-with-graphify/SKILL.md deleted file mode 100644 index c728404..0000000 --- a/.claude/skills/analyze-with-graphify/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: analyze-with-graphify -description: Mandatory Graphify routing gate for every source-code-dependent task. Use before locating, reading, explaining, planning, changing, debugging, reviewing, or verifying code; tracing callers, dependencies, data or control flow; estimating impact; or checking an implementation claim against a repository, even when the user does not mention Graphify. Check the current session for the official native graphify skill and any more specific Graphify skills, invoke them first, then inspect the actual source. Do not use Graphify as the primary parser, search engine, or authority for raw Markdown or curated knowledge files. ---- - -# Require Graphify First - -This skill owns mandatory workflow routing. The official native `graphify` -skill supplied by Graphify owns tool-specific execution. - -## Mandatory trigger - -Invoke this skill before any source-code-dependent understanding or change, -including implementation, investigation, planning, review, verification, -debugging, navigation, impact analysis, architecture work, and verification of -knowledge claims against code. Do not wait for the maintainer to mention -Graphify. - -Do not invoke it merely to read or reconcile Markdown, specs, raw intake, -curated concepts, prose documentation, or workflow configuration. Those use -QMD retrieval plus direct reading and their native structure. Invoke Graphify -when that work crosses the boundary into a source repository. - -## Session skill gate - -1. Inspect the skills exposed in the current session before source-code - navigation. -2. Require the official native skill named `graphify`, or a provider-namespaced - equivalent whose metadata identifies the official Graphify skill. Do not - count this `analyze-with-graphify` routing skill as the native skill. -3. Identify any additional Graphify-specific skills in the session and invoke - the most specific relevant one after loading the native skill. -4. Treat the current session catalog as authoritative. A skill file present on - disk may require an agent restart before it becomes active. -5. Invoke the native `graphify` skill and follow its complete procedure before - continuing the project task. - -If the native skill is absent, stop repository analysis: - -- If the `graphify` CLI is also absent, offer: - - ```sh - uv tool install graphifyy - graphify install --platform <agent> - ``` - -- If the CLI exists, offer only the matching - `graphify install --platform <agent>` command. Use `graphify install --help` - to resolve the current platform name instead of guessing it. -- Tell the maintainer to restart the agent session after installation. -- Do not install user-level tooling without authority and do not silently fall - back to a partial hand-written Graphify procedure. - -## Project obligations - -1. Use the native skill to query an existing `graphify-out/graph.json` - immediately or build/update the graph when required. -2. Trace source-code relationships through the graph before drawing - conclusions. -3. Open and inspect the actual source locations returned by Graphify. The - source at the recorded Git revision, not the graph, is implementation - authority. -4. Only then use `rg` or equivalent text search for exact tokens, literals, - generated artifacts, or gaps not represented in the graph. -5. Record relevant queries, paths, and source locations in the active spec or - curation record. - -## Honesty - -- Distinguish extracted edges from inferred or ambiguous edges. -- Do not turn a missing graph result into proof that code does not exist. -- State when the graph is stale and update it before relying on changed sources. -- Do not substitute grep output for relationship analysis. -- Do not cite `graphify-out/` as proof in curated knowledge. Cite pinned source - locations and fresh checks reached through Graphify. diff --git a/.claude/skills/analyze-with-graphify/agents/openai.yaml b/.claude/skills/analyze-with-graphify/agents/openai.yaml deleted file mode 100644 index c3ec6d8..0000000 --- a/.claude/skills/analyze-with-graphify/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Require Graphify First" - short_description: "Mandatory Graphify gate for source code" - default_prompt: "Use $analyze-with-graphify before any source-code-dependent analysis or change, confirm the native Graphify skills are active, invoke them first, and verify results in the actual source." diff --git a/.claude/skills/api-integration/SKILL.md b/.claude/skills/api-integration/SKILL.md deleted file mode 100644 index b619cbf..0000000 --- a/.claude/skills/api-integration/SKILL.md +++ /dev/null @@ -1,376 +0,0 @@ ---- -name: api-integration -description: Use whenever client code integrates with any external data source or API, including REST, GraphQL, RPC, IPC or native bridges, SDKs, gateways, microservices, WebSockets, generated contracts, and mock or fixture data used to scaffold application data. Apply when creating or changing API clients, transport or auth middleware, api/modules boundaries, contract generation or inferred types, request functions, query keys, query or mutation registries, pagination, query balancing with TanStack Pacer, batching, cache updates or invalidation, and components or hooks that consume API data. ---- - -# API Integration - -Treat `api/` as the client application's boundary with external sources. Keep -transport details, external contracts, server-state identities, and cache -effects behind that boundary so feature code consumes a stable, typed module -instead of speaking directly to a transport. - -The architecture is independent of the transport: - -```text -external contract authority - -> generated or inferred contract - -> typed source client - -> source module operations - -> key registry - -> query and mutation registries - -> application consumers -``` - -OpenAPI, GraphQL code generation, a schema registry, a typed SDK, and a native -RPC bridge are different ways to supply the first three links. They do not -change the remaining shape. - -## Start by finding the real source - -Before editing: - -1. Identify every external source touched by the change. -2. Find the authoritative contract for each source. -3. Find how its client is constructed and where cross-cutting behavior lives. -4. Trace the existing key, query, mutation, and invalidation paths. -5. Check how generated artifacts are produced and validated. - -Do not invent a second integration path because a call is small. Extend the -source module that already owns the contract and transport. - -## Place integrations under `api/` - -Prefer this application boundary: - -```text -api/ - client.ts - provider.tsx - modules/ - auth/ - service-a/ - service-b/ -``` - -- Put client-side integrations with external sources under `api/`. -- When the application has multiple sources, give each source its own - `api/modules/<source>/` directory. -- A source may be a separately governed service, a gateway-backed service - surface, a third-party SDK, or a native capability. The network address is - not the boundary: multiple services behind one gateway remain separate - modules when their contracts and ownership are separate. -- Name modules after the external source or contract authority, not after a - screen, component, or user journey. -- Split a large source module internally by operation or resource when useful, - but keep one public module surface. -- Keep source-neutral infrastructure such as the shared cache client and its - provider at the `api/` root. - -Feature components must not import generated transports, raw SDKs, RPC clients, -or wire contracts directly. They consume the source module. - -## Derive types; do not curate DTOs - -Use contract types in this order: - -1. Types generated from the authoritative external contract. -2. Types inferred from runtime schemas or a typed registry. -3. Request and response types exposed by a typed SDK or RPC client. -4. Narrow aliases or projections derived from one of the above. -5. Hand-written boundary types only when no machine-readable or typed contract - exists. - -Never duplicate an external DTO by manually restating its fields. Regenerate or -re-infer it. - -Do not create a hand-maintained `types.ts` dumping ground. A `types.ts` file -written by a generator is a generated artifact, not a dumping ground: mark it -as generated, never edit it manually, and make the generator its source of -truth. Genuine client-only models should be: - -- inferred from a runtime schema when validation is required; -- colocated with the operation that owns them; -- named after their responsibility rather than placed in a generic type bag; -- kept outside the API contract when they are view, form, or component state. - -An alias is useful when it creates a stable public name for an unreadable -generated lookup. An alias that merely duplicates another local alias is not. - -## Keep mock data as scaffolding - -Treat mock data as temporary display scaffolding, not as a half-built domain -layer. Keep mock-only data behind one obvious nearest boundary: prefer the -project's established API mocking or fixture boundary; otherwise colocate it -with the source module or consumer that owns it. - -- Store literal display text and values directly in the fixture. -- Reuse real generated, inferred, SDK, or package contracts when the represented - records already exist. -- Do not invent exported domain types, fake service methods, resolvers, - registries, adapters, or parser-style utilities for functionality that does - not exist. -- Do not normalize or transform static fixture data merely to imitate a future - production integration. -- Remove or replace the mock boundary when the real source becomes available; - do not let both become competing authorities. - -## Make generation reproducible - -When contract generation is available: - -- keep the upstream contract or a deterministic contract-sync command; -- map one independently governed source to one module output; -- generate into a predictable, reviewable location; -- include a generated-file header; -- fail when required contract inputs are missing; -- detect stale outputs after sources are renamed or removed; -- expose one documented command that can be rerun locally and in CI; -- validate that regeneration leaves the worktree unchanged. - -Do not edit generated output to make a consumer compile. Fix the authority, -generator, or adapter. - -## Construct clients at the boundary - -Put shared transport concerns in an API client factory or a source-specific -client: - -- base URL or channel selection; -- authentication and refresh coordination; -- protocol-required request and response payload encoding; -- retry and timeout policy; -- transport-level logging; -- middleware or interceptors. - -Create a dedicated client when a source needs different middleware, such as an -authentication endpoint that must not invoke its own refresh interceptor. - -Do not put query keys, cache invalidation, navigation, toasts, or component -state in the transport client. The client moves typed messages; the source -module owns server-state semantics. - -## Give every cached source a stable public module surface - -For a TanStack Query integration, prefer this top-to-bottom order: - -1. Intentional public aliases derived from the contract, if needed. -2. The typed source client. -3. Plain transport operations, when reuse or focused testing warrants them. -4. `<source>Keys`. -5. `<source>Queries`. -6. `<source>Mutations`. -7. Source-local helpers. - -The registries are plain values and factories outside React. Components choose -`useQuery`, `useSuspenseQuery`, `useMutation`, prefetching, or route loading; -the API module supplies the canonical options. - -If the repository uses another server-state library, preserve the same -separation: stable identities, reusable read definitions, reusable write -definitions, and explicit post-write cache effects. - -## Build a hierarchical key registry - -Export one key registry per source module. - -- Start with an immutable `all` namespace key. -- Derive narrower keys by spreading their parent key. -- Include every argument that changes the response. -- Put stable labels before dynamic values. -- Keep key values serializable and deterministic. -- Use the same registry for queries, mutations, prefetching, cache writes, and - invalidation. -- Design useful prefixes for broad invalidation, list invalidation, and exact - entity invalidation. - -There is no arbitrary maximum key depth. The hierarchy should describe cache -identity and invalidation boundaries, not satisfy a visual limit. - -```ts -export const accountsKeys = { - all: ["accounts"] as const, - lists: () => [...accountsKeys.all, "list"] as const, - list: (filters: AccountFilters) => [...accountsKeys.lists(), filters] as const, - details: () => [...accountsKeys.all, "detail"] as const, - detail: (id: string) => [...accountsKeys.details(), id] as const, - mutations: () => [...accountsKeys.all, "mutation"] as const, -}; -``` - -## Export query definitions as a registry - -Query factories own the canonical relationship between a key and its fetch: - -```ts -export const accountsQueries = { - list: (filters: AccountFilters) => - queryOptions({ - queryKey: accountsKeys.list(filters), - queryFn: () => listAccounts(filters), - }), - detail: (id: string) => - queryOptions({ - queryKey: accountsKeys.detail(id), - enabled: Boolean(id), - queryFn: () => getAccount(id), - }), -}; -``` - -- Use `queryOptions` or the repository's equivalent so consumers share one - definition. -- Gate a query when required identity is unavailable. -- Keep UI effects out of query functions. -- Return contract data or an intentional boundary normalization, not a - component-shaped view model. -- For pagination, make the cursor or offset part of the query definition and - implement a real exhaustion condition. - -Plain transport functions are optional. Keep them when multiple query -definitions reuse an operation, non-React code calls it, or focused tests need -the transport boundary. Otherwise a typed client call may live directly in the -query function. - -## Export mutation definitions and declare cache effects - -Define reusable mutation option factories outside React: - -```ts -export const accountsMutations = { - rename: () => - mutationOptions({ - mutationKey: [...accountsKeys.mutations(), "rename"] as const, - mutationFn: renameAccount, - onSettled: (_data, _error, variables) => { - void queryClient.invalidateQueries({ queryKey: accountsKeys.lists() }); - void queryClient.invalidateQueries({ - queryKey: accountsKeys.detail(variables.id), - }); - }, - }), -}; -``` - -Every write must make its cache effects explicit: - -- invalidate the exact entity when only that entity can change; -- invalidate a list prefix when membership or ordering can change; -- invalidate the source root only when the effect is genuinely broad; -- invalidate other source modules when the write changes their data; -- update cache directly only when the returned contract data is sufficient to - do so without guessing; -- state intentionally when a mutation has no cached read effect. - -Prefer `onSettled` when the server may have changed state despite an error or -when this is the repository convention. Use `onSuccess` when the protocol -guarantees no state change on failure and avoiding a failed-write refetch is -important. Correctness determines the callback, not habit. - -Consumers own UI effects such as navigation, notifications, dialog state, and -form reset. They must not replace the module's cache lifecycle accidentally. -Prefer per-call callbacks or explicitly compose handlers when adding local -behavior. - -## Preserve transport errors - -Propagate the transport's original error value unchanged by default. Prefer the -transport's own error primitives and handling mechanisms, such as an -`AxiosError`, a Fetch `Response` or rejection, or an SDK/RPC error type. - -Do not serialize, deserialize, normalize, wrap, map, clone, or reconstruct an -error merely to create a uniform API shape. In particular: - -- do not replace a transport error with a generic `Error`; -- do not invent `ApiError`, `toApiError`, error-code maps, registries, or - conversion helpers; -- do not discard the original prototype, status, headers, body, code, cause, - retry metadata, or transport-specific guards; -- do not mutate errors while logging them; -- when a protocol returns a typed failure value rather than throwing, propagate - it as-is; throw that value directly when the query or mutation boundary - requires a rejection. - -If the project already has a specialized error registry, adapter, serializer, -or application-wide error flow, use it exactly as designed. Verify that it is -the established integration path before relying on it. Do not create or extend -such an abstraction just for the current API integration unless the task -explicitly requires changing the project's error architecture. - -Keep presentation outside the API module: components or the existing -application error flow own translated messages, toasts, and recovery UX. - -## Balance compatible queries through one batch client - -When many independent query functions target the same batch-capable source -operation, place one source-owned query balancer between them and the transport. -Let TanStack Query continue to own caching and identical-key deduplication. Let -TanStack Pacer collect distinct, temporally adjacent requests and flush them as -one transport batch. - -- Create one batcher per compatible source operation and auth, tenant, locale, - or routing context. Never choose transport context from the first queued item. -- Let every enqueue return its own promise. Preserve its `resolve` and `reject` - callbacks until the batch result is routed back to that caller. -- Flush after a short coalescing delay or when `maxSize` is reached. Treat - `maxSize` as a batch trigger, not as queue-capacity rejection. -- Route responses by array position only when the transport contract guarantees - stable ordering. Otherwise correlate them by an existing request identifier. -- Reject every affected caller with the original batch-level transport error. - Deliver per-item failure values to their matching callers unchanged. -- Keep results in TanStack Query's cache, not in the batcher. -- Avoid priority reordering unless correlation is explicit. -- Do not connect one query consumer's abort signal to a shared batch abort. -- Do not enable retries for writes or non-idempotent reads without an explicit - source guarantee. -- Expose `flush` only when a latency-sensitive caller genuinely needs it. - -Use atomic or manually constructed write batches only when the source contract -defines their ordering, partial-failure, and idempotency semantics. Invalidate -all affected cache prefixes after a successful or potentially applied write. - -Read `references/query-balancing.md` before implementing Pacer-based batching. -It contains a thin generic implementation and the lifecycle invariants that -prevent lost, crossed, or permanently pending query promises. - -## Verification - -Before finishing an API change, verify: - -- external calls remain behind `api/`; -- each source has one clear module owner; -- generated artifacts reproduce from their authority; -- no external DTO was manually duplicated; -- no hand-maintained generic `types.ts` was introduced; -- mock-only data stays behind one explicit boundary and does not introduce a - parallel domain model or fake integration layer; -- keys contain every response-changing input; -- query and mutation definitions are reusable outside components; -- each mutation's cache effects are explicit and tested; -- cross-source invalidation is covered; -- query batchers preserve one-to-one result routing and leave no caller promise - pending after success, failure, flush, or exposed cancellation; -- transport errors remain unchanged unless an existing project-wide error flow - explicitly owns their conversion; -- no new error wrapper, converter, serializer, or registry was invented; -- auth, retry, and error behavior is tested at the client boundary; -- typecheck, focused tests, and contract-generation drift checks pass. - -## References - -- Read `references/module-pattern.md` for a neutral source-module skeleton. -- Read `references/query-balancing.md` when multiple query functions can share - a source batch operation or when using TanStack Pacer. -- Read `references/transport-examples.md` when choosing how generated, - schema-inferred, SDK, or RPC contracts feed the same module architecture. - -## Related skills - -- Client state that does not mirror an external source: `state-management`. -- Query or mutation failure propagation, reporting, retry, and duplicate - suppression: `error-handling`. The no-invention rule above remains binding. -- User-visible query and mutation outcomes: `user-feedback`. -- Wiring mutations to form submission: `forms`. -- Broader source-tree ownership decisions: `file-structure`. -- Rendering loading, error, empty, and data states from an API query: - `components`. diff --git a/.claude/skills/api-integration/references/module-pattern.md b/.claude/skills/api-integration/references/module-pattern.md deleted file mode 100644 index a3c9e48..0000000 --- a/.claude/skills/api-integration/references/module-pattern.md +++ /dev/null @@ -1,157 +0,0 @@ -# Source Module Pattern - -This example uses TanStack Query because it makes the registries concrete. -Replace the transport and cache adapter without changing the ownership model. - -## Suggested layout - -```text -api/ - client.ts - provider.tsx - modules/ - accounts/ - index.ts - source.ts - contract.generated.ts -``` - -`source.ts` adapts the repository's typed transport. `contract.generated.ts` is -optional: it may instead live in a generated package or be unnecessary when a -typed SDK, schema registry, or RPC client already exposes the contract. - -## Shared API infrastructure - -```ts -// api/client.ts -import { QueryClient } from "@tanstack/react-query"; - -export const queryClient = new QueryClient(); -``` - -```tsx -// api/provider.tsx -import { QueryClientProvider } from "@tanstack/react-query"; -import type { PropsWithChildren } from "react"; - -import { queryClient } from "./client"; - -export function APIProvider({ children }: PropsWithChildren) { - return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; -} -``` - -## Source module - -```ts -// api/modules/accounts/index.ts -import { - infiniteQueryOptions, - mutationOptions, - queryOptions, -} from "@tanstack/react-query"; - -import { queryClient } from "@/api/client"; - -import { - accountsSource, - type AccountFilters, - type RenameAccountRequest, -} from "./source"; - -export const accountsKeys = { - all: ["accounts"] as const, - lists: () => [...accountsKeys.all, "list"] as const, - list: (filters: AccountFilters) => [...accountsKeys.lists(), filters] as const, - infinite: (filters: AccountFilters) => - [...accountsKeys.lists(), "infinite", filters] as const, - details: () => [...accountsKeys.all, "detail"] as const, - detail: (id: string) => [...accountsKeys.details(), id] as const, - mutations: () => [...accountsKeys.all, "mutation"] as const, -}; - -export async function listAccounts(filters: AccountFilters) { - return accountsSource.list({ filters }); -} - -export async function getAccount(id: string) { - return accountsSource.get({ id }); -} - -export async function renameAccount(variables: { - id: string; - request: RenameAccountRequest; -}) { - return accountsSource.rename(variables); -} - -export const accountsQueries = { - list: (filters: AccountFilters) => - queryOptions({ - queryKey: accountsKeys.list(filters), - queryFn: () => listAccounts(filters), - }), - infinite: (filters: AccountFilters) => - infiniteQueryOptions({ - queryKey: accountsKeys.infinite(filters), - initialPageParam: undefined as string | undefined, - queryFn: ({ pageParam }) => - listAccounts({ - ...filters, - cursor: pageParam, - }), - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }), - detail: (id: string) => - queryOptions({ - queryKey: accountsKeys.detail(id), - enabled: Boolean(id), - queryFn: () => getAccount(id), - }), -}; - -export const accountsMutations = { - rename: () => - mutationOptions({ - mutationKey: [...accountsKeys.mutations(), "rename"] as const, - mutationFn: renameAccount, - onSettled: (_data, _error, variables) => { - void queryClient.invalidateQueries({ queryKey: accountsKeys.lists() }); - void queryClient.invalidateQueries({ - queryKey: accountsKeys.detail(variables.id), - }); - }, - }), -}; -``` - -The source adapter, contract types, keys, operations, and cache definitions form -one reviewable integration boundary. A large module may split these into named -files without changing its public surface. - -`source.ts` may wrap generated HTTP code, a schema-inferred command transport, -or a typed RPC/SDK client. It propagates transport errors unchanged unless the -project already has a specialized error flow that owns their conversion. See -`transport-examples.md`; no variant is the default. - -## Consumer - -```tsx -const account = useQuery(accountsQueries.detail(accountId)); -const renameAccount = useMutation(accountsMutations.rename()); - -function submit(request: RenameAccountRequest) { - renameAccount.mutate( - { id: accountId, request }, - { - onSuccess: () => { - showSuccess("Saved"); - closeDialog(); - }, - }, - ); -} -``` - -The per-call callback adds UI behavior without replacing the mutation -registry's cache lifecycle. diff --git a/.claude/skills/api-integration/references/query-balancing.md b/.claude/skills/api-integration/references/query-balancing.md deleted file mode 100644 index 566703e..0000000 --- a/.claude/skills/api-integration/references/query-balancing.md +++ /dev/null @@ -1,245 +0,0 @@ -# Query Balancing with TanStack Pacer - -Use a query balancer when many independent callers request different data from -the same batch-capable source operation. The balancer coalesces nearby requests, -executes one transport call, and routes each result back to the promise created -for its original caller. - -## Contents - -- Purpose and ownership -- Minimal implementation -- Source-module integration -- Contract and lifecycle rules -- Verification - -## Purpose and ownership - -This is temporal request balancing, not server load balancing: - -```text -query A ─┐ ┌─ result A -> query A -query B ─┼─ short queue ─ batch call ─ result B -> query B -query C ─┘ └─ result C -> query C -``` - -TanStack Query and TanStack Pacer have different jobs: - -- TanStack Query caches results, deduplicates identical query keys, and manages - query lifecycle. -- TanStack Pacer groups distinct requests that arrive close together and - controls when the group executes. -- The source module owns the batch endpoint and the one-to-one mapping between - queued requests and returned results. - -Keep one long-lived balancer per batch-capable source operation. Do not create a -batcher per component or per query invocation. - -## Minimal implementation - -Use the core Pacer class for a non-React service. If the repository exposes the -same class through its framework package, follow the established import. - -```ts -import { AsyncBatcher } from "@tanstack/pacer"; - -type PendingRequest<TRequest, TResponse> = { - request: TRequest; - resolve: (response: TResponse) => void; - reject: (reason: unknown) => void; -}; - -type QueryBatcherOptions<TRequest, TResponse> = { - key: string; - maxSize: number; - wait: number; - execute: ( - requests: readonly TRequest[], - ) => Promise<readonly TResponse[]>; -}; - -export function createQueryBatcher<TRequest, TResponse>({ - key, - maxSize, - wait, - execute, -}: QueryBatcherOptions<TRequest, TResponse>) { - const batcher = new AsyncBatcher< - PendingRequest<TRequest, TResponse> - >( - async (pending) => { - const responses = await execute( - pending.map(({ request }) => request), - ); - - if (responses.length !== pending.length) { - throw new Error( - `Batch response count mismatch: expected ${pending.length}, received ${responses.length}`, - ); - } - - pending.forEach(({ resolve }, index) => { - resolve(responses[index]!); - }); - }, - { - key, - maxSize, - wait, - onError: (error, pending) => { - pending.forEach(({ reject }) => { - reject(error); - }); - }, - throwOnError: false, - }, - ); - - return { - request(request: TRequest): Promise<TResponse> { - return new Promise<TResponse>((resolve, reject) => { - void batcher.addItem({ request, resolve, reject }); - }); - }, - async flush(): Promise<void> { - await batcher.flush(); - }, - }; -} -``` - -The wrapper is intentionally thin: - -- Pacer owns collection, timing, `maxSize`, execution, and observable state. -- The wrapper owns only the caller promise and result demultiplexing. -- `throwOnError: false` prevents the ignored `addItem` promise from producing - an unhandled rejection; `onError` rejects the real caller promises with the - original error. -- The cardinality error represents a broken batch contract. It does not wrap or - convert a transport error. - -## Source-module integration - -Construct the balancer once inside the source module: - -```ts -type AccountRequest = { - id: string; -}; - -const accountDetailBatcher = createQueryBatcher< - AccountRequest, - AccountResponse ->({ - key: "accounts.detail", - maxSize: 50, - wait: 20, - execute: (requests) => accountsSource.getMany(requests), -}); - -export function getAccount(id: string) { - return accountDetailBatcher.request({ id }); -} - -export const accountsQueries = { - detail: (id: string) => - queryOptions({ - queryKey: accountsKeys.detail(id), - enabled: Boolean(id), - queryFn: () => getAccount(id), - }), -}; -``` - -Concurrent components, route loaders, and prefetches now use the same -`accountsQueries.detail` definition. They do not know whether their request was -sent alone or as part of a batch. - -If the transport returns a per-item failure envelope, deliver that envelope to -the matching caller unchanged. Let the source operation apply the project's -existing direct error handling. Do not make the generic balancer understand -domain statuses or invent a common error type. - -## Contract and lifecycle rules - -### Batch only compatible work - -All items in one batch must share: - -- source and batch operation; -- base URL or transport channel; -- authentication and tenant context; -- locale or headers that affect the response; -- retry, timeout, and consistency semantics. - -Close these dependencies over the batcher's `execute` function. Do not store a -client, session, processor, or options on every item and then use the first -item's values for the entire batch. - -### Preserve correlation - -Use positional routing only when the source guarantees response order. Do not -sort or reprioritize queued items in that case. - -When ordering is not guaranteed, send an existing correlation identifier and -route responses through a lookup: - -```ts -const pendingById = new Map( - pending.map((item) => [item.request.requestId, item]), -); - -for (const response of responses) { - pendingById.get(response.requestId)?.resolve(response); -} -``` - -Reject unmatched requests as a batch-contract violation. Never guess which -caller owns a response. - -### Preserve error identity - -- Reject every item with the original error when the entire batch call fails. -- Preserve each transport-provided per-item error or failure envelope. -- Do not serialize, normalize, or wrap errors in the balancer. -- Use an existing project-wide error flow only when the project already - requires it. - -### Keep cancellation collective - -An in-flight transport batch belongs to several callers. Aborting it because -one component unmounted would cancel unrelated queries. Do not wire an -individual query signal directly to the batcher's shared abort signal. - -If individual cancellation is required, mark or remove only that pending item -before execution and settle its promise with the original abort reason. If -exposing `cancel`, `clear`, or `reset`, settle every removed caller first; -otherwise their promises remain pending forever. - -### Retry only safe batches - -Pacer can retry a failed batch, but retrying repeats every item. Enable it only -when the whole operation is idempotent and the source defines safe retry -semantics. Keep retries off by default for mutations and mixed-effect batches. - -### Separate query balancing from write batching - -Query balancing is most useful for independent reads. A write batch has -additional atomicity, ordering, partial-success, idempotency, and invalidation -requirements. Do not send mutations through a query balancer merely because the -transport accepts an array. - -## Verification - -Test the balancer with deterministic test executors: - -- several requests inside the window produce one batch call; -- reaching `maxSize` flushes immediately; -- each caller receives the response at its own position or correlation id; -- a batch-level error rejects every caller with the same error object; -- a per-item failure reaches only its matching caller; -- a response-count or correlation mismatch settles every affected promise; -- a later batch cannot resolve promises from an earlier batch; -- explicit `flush` settles all currently queued requests; -- cancellation or clearing, when exposed, leaves no promise pending; -- retries occur only when the operation is explicitly idempotent. diff --git a/.claude/skills/api-integration/references/transport-examples.md b/.claude/skills/api-integration/references/transport-examples.md deleted file mode 100644 index 64442fd..0000000 --- a/.claude/skills/api-integration/references/transport-examples.md +++ /dev/null @@ -1,133 +0,0 @@ -# Contract and Transport Variants - -These variants are peers. Select the one supported by the source authority; -none changes the source-module, key-registry, query-registry, mutation-registry, -or invalidation pattern. - -## Generated HTTP contract - -Use this when an OpenAPI or similar machine-readable contract is authoritative. - -```ts -import createClient from "openapi-fetch"; - -import type { components, paths } from "./contract.generated"; - -export type CreateAccountRequest = - components["schemas"]["CreateAccountRequest"]; - -const accountsClient = createClient<paths>({ baseUrl }); - -export async function createAccount(request: CreateAccountRequest) { - const { data, error } = await accountsClient.POST("/accounts", { - body: request, - }); - - if (error) { - throw error; - } - - return data; -} -``` - -The generator owns `contract.generated.ts`. The module may export readable -aliases derived from it, but must not restate DTO fields. - -The same approach applies to GraphQL, protobuf/gRPC, and other contract -generators: generate the client or types, then adapt them behind the source -module. - -## Runtime schema or registry - -Use this when the source publishes executable schemas or a typed command -registry. - -```ts -import type { resources } from "@vendor/source-registry"; -import type { z } from "zod"; - -type ListAccountsParams = z.infer< - typeof resources.accounts.list.request.shape.details ->; - -export async function listAccounts(params: ListAccountsParams) { - const response = await sendTypedCommand< - typeof resources.accounts.list - >({ - action: "get", - resource: "/accounts", - details: params, - }); - - if (response.status !== "success") { - throw response; - } - - return response; -} -``` - -Types are inferred from the registry that also validates runtime messages. -The typed failure envelope is propagated unchanged. Avoid both a parallel -hand-written DTO layer and an invented error-conversion layer. - -## Typed SDK or native RPC client - -Use this when the client method already carries request and response types. - -```ts -import { AccountsNativeModule } from "@internal/accounts/native/client"; -import { queryOptions } from "@tanstack/react-query"; - -export const accountsKeys = { - all: ["accounts"] as const, - detail: (id: string) => [...accountsKeys.all, "detail", id] as const, -}; - -export const accountsQueries = { - detail: (id: string) => - queryOptions({ - queryKey: accountsKeys.detail(id), - enabled: Boolean(id), - queryFn: () => AccountsNativeModule.getAccount({ id }), - }), -}; -``` - -No local request or response aliases are required when inference remains -readable at the call site. The typed RPC contract and client factory are the -type authority. - -## Dedicated unauthenticated client - -Some operations must not use normal source middleware. Authentication refresh -is the common example: - -```ts -const sourceClient = createAuthenticatedClient<paths>(); -const authClient = createBareClient<authPaths>(); -``` - -Keep this exception explicit inside the owning source module or client -infrastructure. Do not add component-level flags that disable middleware for -individual calls. - -## Contract-derived projection - -Client code sometimes needs a subset or combination of contract types. Derive -it instead of copying it: - -```ts -type SearchParams = NonNullable< - paths["/accounts"]["get"]["parameters"]["query"] ->; - -type AccountSummary = Pick< - components["schemas"]["Account"], - "id" | "display_name" ->; -``` - -If the shape exists only for a form or view, keep it with that form or view. -It is not part of the API contract. diff --git a/.claude/skills/code-quality/SKILL.md b/.claude/skills/code-quality/SKILL.md deleted file mode 100644 index 00bafaf..0000000 --- a/.claude/skills/code-quality/SKILL.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -name: code-quality -description: Use whenever cleaning, validating, or finishing code; fixing or preventing formatting, lint, typecheck, import-order, naming, or other repository-standard violations; deciding whether the formatter or linter should autofix mechanical issues instead of editing them by hand; or running verification commands before calling work complete. ---- - -# Code Quality - -Keep code mechanically clean through the repository's own type checker, linter, -formatter, and build tooling. Treat every rule here as a default unless the -repository's configuration says otherwise; its configuration is authoritative. - -## Verification (run before done) - -Always run the repo's typecheck, lint, format, and build before declaring a -change complete. The script runner is repo-specific; these are the SAME step -expressed two ways: - -```bash -# pnpm-based repo # bun-based repo -pnpm typecheck bun run typecheck -pnpm lint bun run lint -pnpm format bun run format:check # check; *:fix to auto-fix -pnpm build bun run build -``` - -- For mechanical formatting, import-order, and autofixable lint violations, run - the repository's configured formatter or lint-fix command before editing the - affected lines manually. -- Let the tool apply the rules it owns, then inspect its diff and fix only the - remaining semantic or non-autofixable violations by hand. -- Do not imitate formatter or linter output manually when the repository already - provides the command that produces it. -- Run from the repo's canonical directory (root or the app package — follow the - repo's scripts; some lint scripts run with `--fix --max-warnings=0`). -- Fix everything to zero warnings/errors. Do not leave a red checker. -- If the change touches native/main-process code or packaging, also run the - relevant native build/dev check — see **native-integration**. - -## TypeScript: strict and honest - -Keep strict mode on. Expect (and do not disable) at least: - -```jsonc -{ - "strict": true, - "strictNullChecks": true, - "noImplicitAny": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true -} -``` - -Typing principles: - -- Prefer `unknown` + narrowing over `any`. Narrow with `typeof`, `instanceof`, - or `in` guards before accessing properties. -- Use `enum` for named constant sets; reach for `as const` objects when an enum - is heavier than the context needs, then derive `type T = typeof X[keyof typeof X]`. -- Use `satisfies` to type-check a literal without widening its inferred type. -- Derive types from their contract source, not by hand-duplication: `z.infer` - from a Zod schema, or generated types from an OpenAPI generator — adapt to the - repo's stack (see **api-integration** for contract typing). -- Add explicit return types on exported functions. - -## Path aliases, never deep relatives - -Import through the repo's configured alias (`@/...`, `@config`, `@internal/...`). -Never reach across the tree with `../../../`. - -```ts -// Good // Bad -import { UiButton } from '@/ui/UiButton' import { UiButton } from '../../../ui/UiButton' -``` - -## Linting norms - -- No unused variables. Remove them, or prefix intentionally-unused params with - `_` (e.g. `(_event) => …`). -- No stray `console`. If a log is genuinely required, scope a single - `eslint-disable-next-line` — and prefer the repo's logger (see - **logging**). -- React Hooks rules apply; keep hook dependency lists correct. -- Let the linter sort/dedupe imports. Disable rules narrowly and only with a - reason, never blanket-disable a whole file casually. - -## Import order - -Group imports, blank-line-separated, in this order (the linter usually enforces -it): - -1. React -2. External packages -3. Internal alias imports (`@/...`) -4. Relative imports (`./...`) - -```tsx -import { useState } from 'react' - -import { useQuery } from '@tanstack/react-query' - -import { UiButton } from '@/ui/UiButton' - -import { Child } from './Child' -``` - -## Formatting - -Adopt the repo's formatter — follow its `.prettierrc` (or equivalent) rather than -re-styling by hand; let the formatter own whitespace, quotes, and class sorting. -One repo's baseline, as an example of the kind of conventions to honor: 2-space -indent, no semicolons, single quotes (incl. JSX), trailing commas everywhere, -`arrowParens: avoid`, and a Tailwind class-sorting plugin. Match whatever the -target repo configures; do not impose this baseline on a repo that disagrees. - -## Naming - -- Components: `PascalCase`. Hooks: `camelCase` with `use` prefix. -- Module-level constants: `SCREAMING_SNAKE_CASE`. -- Files: `PascalCase` for components, `camelCase` for utilities. -- CSS: kebab-case utility classes (via Tailwind). The literal-class / `cn()` - rule lives in **components**. - -## Cross-links - -- React component behavior, effect alternatives, and internal ordering — - **components**. -- Logger instead of `console` — **logging**. -- Native/main-process build checks — **native-integration**. diff --git a/.claude/skills/components/SKILL.md b/.claude/skills/components/SKILL.md deleted file mode 100644 index 9ed3a17..0000000 --- a/.claude/skills/components/SKILL.md +++ /dev/null @@ -1,412 +0,0 @@ ---- -name: components -description: Use whenever work creates, modifies, refactors, reviews, styles, composes, or places any React component or conditional JSX; selects or adds a Ui* primitive; ports shadcn/Base UI components; designs compound, polymorphic, Context, or controlled/uncontrolled APIs; handles parent-child layout ownership, hooks and effects, skeletons, loading/error/empty/data states, or render-prop data loaders. Covers UI primitives, common business components, and feature- or page-owned components. ---- - -# Components - -Treat the repository's existing `ui/` directory as the design-system authority. -Its `Ui*` components are local adaptations of official **shadcn** components built -on **Base UI** (`@base-ui/react`), commonly using -`class-variance-authority` (cva) and `cn()` (tailwind-merge). - -Treat shadcn as an upstream source of component code and documentation, not as a -registry-managed project structure. Preserve the local `Ui*` conventions instead -of introducing Radix, HeroUI, another primitive system, or a parallel feature-local -design system. - -## Distinguish Primitives From Product Components - -Reserve the `ui/` directory and `Ui*` prefix for reusable design primitives and -their stable specializations. - -Components that compose those primitives with product behavior are not `Ui*` -components. Name them after their business or interface responsibility and place -them at the nearest common owner that contains all of their real consumers: - -- one consumer → keep it inside that component or page boundary; -- several consumers inside one page subtree → move it only to their nearest - shared owner inside that page; -- several pages or subfeatures inside one feature → move it to that feature's - contextual `components/` boundary; -- consumers from genuinely different features or application areas → place it - in the shared business-component boundary, usually `common/`. - -Do not promote a component based on hypothetical reuse, import count, or reuse -within a single owner. Move it upward only as real consumers cross ownership -boundaries: - -```text -page/components - ↑ -resource-or-feature/components - ↑ -common -``` - -In the usual application layout, `common/` sits beside `ui/`: - -```text -src/ -├── ui/ # Ui* design primitives -└── common/ # Shared product and business components -``` - -Components in `common/` use names that describe their product or business -responsibility; the `Ui*` prefix remains reserved for primitives. Treat -`common/` as a real owner, not as a catch-all. Put only components with -demonstrated use across genuinely different ownership areas there, keep -feature- or page-private components local, and organize complex common -components recursively by responsibility. - -Follow **file-structure** for the exact directory. A private child belongs inside -its owning component or page, commonly under its contextual `components/` -folder. Do not move a component into `ui/` merely because it is reusable, -complex, or implemented as a compound component. - -The component-design rules below are not limited to `Ui*` primitives. Apply -composition, Context, controlled/uncontrolled state, effect, ownership, and -render-state guidance to product components whenever those patterns make the -component simpler and more coherent. - -## Inspect The Existing UI System First - -Before implementing or styling any component: - -1. Decide whether the responsibility is a design primitive or a product - component, then inspect the nearest current owner. -2. Locate the repository's `ui/` directory from existing imports, aliases, and - source structure so the component reuses the available primitives. -3. Inspect its filenames and search for the needed behavior, not only the exact - name you expect. -4. Read the closest existing `Ui*` primitive, its named variations, and a few - real consumers. Read stories when the repository has them. -5. Reuse the existing primitive or variation when it already expresses the - required semantics and state. -6. Add a shared primitive only when the capability is genuinely missing. - -Do not enumerate the available primitives in this skill; discover the target -repository's current UI surface every time. Do not hand-roll or restyle an -interactive control before checking whether its `Ui*` equivalent already exists. - -## Port Missing Primitives From Official Sources - -When the required primitive is absent: - -1. Read the current official shadcn documentation and the matching Base UI API. -2. Select the Base UI implementation, not a Radix or another-library variant. -3. Obtain the official source manually. Use the shadcn CLI only as an optional - way to inspect or download source; do not let it initialize, restructure, or - overwrite the application's `ui/` directory. -4. Inspect analogous local primitives to learn import paths, tokens, formatting, - exports, and file shape. -5. Port the source into the local `Ui*` layer and adapt only its integration: - local `cn`, theme tokens, icon system, `data-slot` values, import aliases, and - `Ui*` exports. -6. Preserve the upstream semantics, accessibility behavior, keyboard behavior, - state attributes, prop forwarding, and ref contract. -7. Add stories or focused tests when that is the repository's established - practice, then run the normal verification through **code-quality**. - -Never paste an upstream component directly into a feature. The local `Ui*` -adaptation becomes the reusable boundary. - -## Ui* Wrapper Convention - -A `Ui*` module adapts one or more shadcn/Base UI parts into the application's -reusable design primitive. - -- Keep internal component names aligned with upstream, then alias every public - export with the `Ui` prefix: - `export { Button as UiButton, buttonVariants as UiButtonVariants }`. -- For multi-part primitives, preserve the parts and alias each public part: - `DialogContent as UiDialogContent`, `DialogTrigger as UiDialogTrigger`, and so - on. -- Put stable `data-slot` attributes on rendered parts. Preserve upstream props - and refs instead of narrowing the primitive accidentally. -- Use cva for real variant axes and compose caller classes through `cn(...)`. -- Prefer an existing wrapper before writing a new one. Add a new `Ui*` only for a - genuinely reusable design primitive — never for one feature's one-off layout. -- Use the icon system the repo already established; do not introduce another. - -## Keep Styling At The Owning Layer - -The shared `Ui*` layer owns the visual language of controls and surfaces: -colors, typography treatments, backgrounds, borders, radii, shadows, and -hover/focus/active/disabled/invalid/ARIA state styling. - -Feature and page components should use Tailwind primarily for structure: -layout, positioning, sizing, spacing, responsive arrangement, and placement of -children. Passing `className` for those structural concerns is expected. - -Do not rebuild a primitive's visual or interaction states in a consumer. If a -visual treatment is intentional and reusable, add a clearly named variation -beside the primitive. If it is unique feature composition rather than a reusable -primitive treatment, compose existing `Ui*` parts without redefining their -owned states. - -## File & Folder Layout — base + variations - -Choose one of three shapes from the component's actual responsibility: - -**1. Self-contained primitive → a flat file** `Ui{Name}.tsx` at the ui root -(`UiCard.tsx`, `UiAlert.tsx`, `UiDialog.tsx`). It may export one component or -several upstream compound parts. - -**2. Primitive with specializations → a folder `Ui{Name}/`** named after the -component and containing: - -- **`base.tsx`** — the canonical local adaptation of the official component. It - contains the shared primitive behavior, state styles, and cva variants and - exports `Ui{Name}` plus any public variants helper. -- **Named visual variations beside `base.tsx`** — one file per stable treatment, - kebab-case named for what it is (`outline-primary.tsx`, - `ghost-destructive.tsx`, `outline.tsx`, `simple.tsx`). Each one: - - imports the original: `import { UiButton } from './base'` - - is a **`default export`** named `Ui{Name}{Variation}` (`UiButtonOutlinePrimary`, - `UiInputOutline`) - - locks a base variant and/or layers extra classes via `cn('…extra', className)`, - narrowing the props it now fixes (`Omit<ComponentProps<typeof UiButton>, 'variant'>`) - - or composes richer stable structure around `base`, such as affixes, field - chrome, or another repeated integration. -- No barrel — import the exact file: `@/ui/UiButton/base`, `@/ui/UiButton/outline-primary`. - -```tsx -// UiButton/outline-primary.tsx -import { UiButton } from './base' - -export default function UiButtonOutlinePrimary({ - className, - ...rest -}: Omit<ComponentProps<typeof UiButton>, 'variant'>) { - return ( - <UiButton - {...rest} - variant='outline' - className={cn('border-primary! text-primary bg-transparent!', className)} - /> - ) -} -``` - -**3. Bespoke/composed component (not a variant family) → `Ui{Name}/index.tsx`** -(+ assets or `components/` subparts): `UiIcon`, `UiMarkdown` (+ `styles.scss`), -`UiToaster` (+ `components/`). - -**Rule of thumb:** a recurring restyle of a `Ui*` belongs in a **named variation -file** under the component folder — not scattered as inline `className` overrides -across features. `className` does merge through `cn()` (caller overrides win), but -promote anything reused into a variation so the base stays the shared authority. - -### Keep the base free of feature-specific changes - -Adapt `base.tsx` intentionally when importing, updating, or correcting the shared -primitive itself. Do not edit it to satisfy one feature's visual request. Build -that treatment around the base through a sibling variation or composition. - -## Polymorphism (render prop, not Slot) - -Base UI primitives are polymorphic via the `render` prop / `useRender` hook + -`mergeProps` — there is no Radix `asChild`/`Slot` here. To let a caller swap the -rendered element, forward `render` or drive it with `useRender`. Ensure custom -render targets accept the forwarded ref and spread the received props onto their -underlying DOM element. Preserve correct element semantics; polymorphism is not -permission to make a button behave as a link or vice versa. - -## Compound Components - -Use the compound pattern when a primitive or business component has meaningful -parts that should compose independently while sharing one coherent behavior. -This includes shadcn-style parts such as `Select.Trigger`/`Select.Content` and -higher-level feature components decomposed into Root, Trigger, Content, -Indicator, or similar roles. - -Compound parts do not require Context by default. Use the local Context boundary -below only when the parts genuinely need shared state or behavior. - -## Context As A Local Composition Boundary - -Use Context when one coherent component, feature, or page subtree has several -parts that need the same state, derived values, or actions, and explicit props -would cause prop drilling or fragmented ownership. - -Place the Provider at the narrowest owner that contains all real consumers. -Keep state and actions in that owner and expose them through a guarded consumer -hook. Derive the context value type from its owning value hook or factory when -practical instead of declaring a parallel type manually. - -```tsx -const XContext = createContext<XState | null>(null) -const useXContext = () => { - const ctx = useContext(XContext) - if (!ctx) throw new Error('X parts must be used within X') - return ctx -} -``` - -Do not introduce Context when local state and explicit props remain clearer. Do -not use it to duplicate server state owned by the data-fetching layer or durable -client state owned by the state-management layer. - -## Controlled / Uncontrolled - -Choose the state contract that fits the component. Do not require every -component to support both modes. - -- Use an uncontrolled mode when the component can own its interaction state. -- Use a controlled mode when a parent must own that state. -- Support both when the same reusable component genuinely needs autonomous and - parent-driven usage. Accept `defaultX` for the uncontrolled seed and - `x` + `onXChange` for controlled usage; never switch modes after mount. -- Use `x !== undefined` only when `undefined` unambiguously means uncontrolled. - If `undefined` is a valid controlled value, define an explicit contract instead. - -## Avoid useEffect - -Treat `useEffect` as exceptional. Before adding one, prove that the component -must synchronize with a lifecycle that no existing declarative abstraction -already owns. The mere presence of an external source does not justify an -effect: when the source fits an async or server-state model, prefer the -repository's data-fetching layer, such as TanStack Query, so request lifecycle, -caching, retries, deduplication, and cancellation stay outside the component. - -Use an effect only as the final option for synchronization that genuinely -belongs to the component, such as an imperative browser API, a third-party -imperative library, or a subscription that cannot use `useSyncExternalStore`. -Do not use it as a general-purpose way to run component logic. - -| Instead of `useEffect` for… | Prefer | -| --- | --- | -| Fetching data | The data-fetching layer, such as TanStack Query — see **api-integration** | -| Derived or computed state | Compute during render, or use `useMemo` for expensive computation | -| Subscribing to an external store | `useSyncExternalStore` or store selectors — see **state-management** | -| Responding to a prop change | Compute during render or lift state up | -| Resetting state on prop change | A `key` prop that remounts the owned subtree | -| Handling a user action | The event handler itself | - -If a React data-flow or event-driven pattern expresses the behavior, use it -instead of adding an effect. - -## Parent Owns Placement - -A child owns its internal structure, content, internal visual treatment, and -local show/hide behavior. It must NOT own the styles that place it within its -parent: surrounding layout, positioning, sibling-dependent sizing, route-level -offsets, or dock dimensions. Apply those external layout styles through a parent -wrapper so the child renders correctly in any correctly-sized slot. - -This rule governs runtime layout and styling ownership, not source-file -placement. Locate the component itself according to **file-structure**. - -### Docked / sheet surfaces — two-layer split - -1. A **generic container** (bar/sheet) owns only open/close behavior and a content slot. -2. The **owning page or layout** applies absolute/flex/grid placement and - surrounding offsets through its wrapper. -3. The **business content** fills the provided slot without knowing the page, - shell, sibling panels, or external geometry. - -The container never decides its own position in the page. - -## Component internal ordering - -Order a component file as imports → types/interfaces → component. Inside the -component, call hooks before any conditional return, then keep computed values, -handlers, and render: - -```tsx -export function MyComponent({ title }: Props) { - const [open, setOpen] = useState(false) - const items = useMemo(() => data?.filter(item => item.active), [data]) - const handleClick = () => setOpen(true) - - return <UiButton onClick={handleClick}>{title}</UiButton> -} -``` - -## Skeletons - -A skeleton must preserve the real content's visible geometry: the same occupied -space, primary dimensions, and row/section arrangement. It does not need to copy -the real DOM or every decorative detail, but swapping in data must not cause a -layout shift. For lists, render several skeleton rows to represent a realistically -populated list. - -## Ordered Async-State Rendering - -For mutually exclusive async states in one UI region, use one ordered waterfall: -loading → error → empty → data. Base blocking loading and error states on the -absence of usable data, not on query flags alone. Existing cached or stale data -normally remains the data state during a background refetch or background error; -surface secondary status separately when the product requires it. - -Prefer an inline IIFE with sequential early returns. Avoid ternary expressions -for conditional rendering in JSX; never build nested or chained render -ternaries. They quickly obscure branch priority and turn JSX into an unreadable -conditional tree. Do not scatter conditionals that can overlap or render -contradictory states. - -```tsx -{(() => { - const hasUsableData = query.data !== undefined - - if (!hasUsableData && query.isPending) return <ListSkeleton /> - if (!hasUsableData && query.isError) return <ErrorState /> - if (!query.data?.items.length) return <EmptyState /> - return <List items={query.data.items} /> -})()} -``` - -Render shared chrome such as the header, title, and primary action once outside -the waterfall. Give separate regions separate waterfalls when they can load or -fail independently. - -## Render-Prop Data-Loading Components (abstract-requesting) - -Consider a thin render-prop requesting component when referenced entities are -loaded repeatedly across UI contexts or direct request wiring would clutter -their consumers. Let it accept the entity identity, call the existing query -options through the shared data-fetching client, and expose the query result to -the render prop. Keep loading, error, empty, and presentation decisions inline -at the call site: - -```tsx -<EntityById id={id}> - {({ data, isPending, isError }) => { - if (data === undefined && isPending) return <UiSkeleton /> - if (data === undefined && isError) { - return <UiAlert>Unable to load</UiAlert> - } - if (data === undefined) return null - return <EntityView entity={data} /> - }} -</EntityById> -``` - -Do not create one reflexively for every entity or embed presentation policy -inside it. Place it at the nearest common owner of its real consumers according -to **file-structure**. - -The render-prop boundary improves declarative composition, not network -performance by itself. Query caching, deduplication, key factories, batching, -and request balancing remain responsibilities of the shared data layer → see -**api-integration**. - -## Cross-References - -- Route/page contracts, layouts, params, and navigation → **routing**. -- Physical file placement, visibility, and ownership boundaries → - **file-structure**. -- Form state, `register`, `Controller`, and validation → **forms**. -- Query and mutation options, keys, cache updates, invalidation, batching, and - request balancing → **api-integration**. -- Durable client state, selectors, persistence, and external-store - subscriptions → **state-management**. -- User-facing labels, placeholders, accessibility text, and messages when the - application uses i18n → **localization**. -- Error propagation, reporting, retry, and recovery boundaries → - **error-handling**. -- Error, success, warning, fallback, and recovery presentation → - **user-feedback**. -- Structured diagnostics and transports → **logging**. -- Lint, formatting, typecheck, and build verification → **code-quality**. diff --git a/.claude/skills/curate-engineering-knowledge/SKILL.md b/.claude/skills/curate-engineering-knowledge/SKILL.md deleted file mode 100644 index 3bc65ea..0000000 --- a/.claude/skills/curate-engineering-knowledge/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: curate-engineering-knowledge -description: Author or materially update engineering-facing current knowledge from verified implementation and reviewed product authority. Use when a completed change, source-first reconstruction, or explicit implementation-drift repair is ready to record architecture, repository ownership, contracts, data or control flow, runtime behavior, operations, or technical constraints. Do not use merely because an engineer asks how existing code works; answer that read-only question through knowledge navigation and Graphify. Keep product meaning in linked product documents and never infer intended behavior from code alone. ---- - -# Curate Engineering Knowledge - -Write the technical realization of current project truth without duplicating or -silently redefining product meaning. - -Read [the engineering writing contract](references/engineering-writing-contract.md) -before first-time promotion. Use -[the engineering concept template](assets/engineering-concept.md) for a new -document. - -## Establish the implementation - -1. Identify the owning Area, product concepts, repository, and exact clean - source revision. -2. Invoke `analyze-with-graphify` for navigation and relationship coverage. -3. Directly inspect source, tests, contracts, configuration, and runtime - evidence at the pinned revision. -4. Distinguish implemented behavior, architectural rationale, ownership, - contract, policy, history, and external claims. Apply the authority required - by each class. -5. Treat code as implementation authority only. Link accepted product meaning; - never derive it from code. - -## Author the engineering view - -1. Declare `view: engineering`, `purpose: technical-realization`, and include - `engineer` or `operator` in `audience`. -2. Explain responsibility, current implementation, ownership boundaries, data - and control flow, contracts and invariants, failure behavior, operations, - and verification. -3. Name exact code surfaces only when they help maintenance or verification. - Pin material claims to repository, commit, path, and optional symbol. -4. Link the product concept that gives the implementation meaning. Keep - product behavior in that product document and describe only the technical - consequence here. -5. Record partial, absent, accidental, retired, unknown, or drifted delivery - honestly. Do not repair intent by rewriting it to match code. -6. Keep cross-Area architecture at `knowledge/architecture/`, repository - ownership at `knowledge/repositories/`, and Area-owned implementation at - `knowledge/areas/<area>/implementation/`. - -The path a page occupies is where it will live, whichever route it takes to get -there. Work from a change bundle writes it under that bundle's `promotion/` -directory at exactly that path, and the maintainer's word is what copies it into -`knowledge/`. Work from a reconstruction or intake case writes it into -`knowledge/` directly, because that promotion is what its closure waits for. - -## Verify before stable - -1. Invoke `verify-knowledge-quality` after the body and evidence are complete. -2. Resolve gaps in source coverage, product linkage, failure behavior, and - claims that exceed their evidence. -3. Run `wfctl knowledge hash --concept <path>` and bind the quality receipt and - normal verification to that hash. -4. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. -5. Do not report completion while any gate fails. diff --git a/.claude/skills/curate-engineering-knowledge/agents/openai.yaml b/.claude/skills/curate-engineering-knowledge/agents/openai.yaml deleted file mode 100644 index 1f6d445..0000000 --- a/.claude/skills/curate-engineering-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Curate Engineering Knowledge" - short_description: "Write verified technical realization knowledge" - default_prompt: "Use $curate-engineering-knowledge to document verified implementation and architecture without duplicating product meaning." diff --git a/.claude/skills/curate-engineering-knowledge/assets/engineering-concept.md b/.claude/skills/curate-engineering-knowledge/assets/engineering-concept.md deleted file mode 100644 index dc2e4d3..0000000 --- a/.claude/skills/curate-engineering-knowledge/assets/engineering-concept.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -type: "<Implementation|Architecture|Repository|Contract|Operational Concept>" -title: "<technical title>" -description: "<one-sentence current technical responsibility>" -status: draft -view: engineering -purpose: technical-realization -audience: - - engineer - - operator - - maintainer -area: "<primary-area when Area-owned>" -capabilities: [] -authority: - - implementation -generated: - by: "<producer>/<version>" - at: "<ISO-8601>" -verified: [] -x-wf: - relations: [] - quality: - status: pending -sources: - - id: "<source-id>" - kind: source-code - resource: "git:<repository>@<40-character-commit>#<path>[:<symbol>]" - title: "<pinned implementation evidence>" ---- - -# Responsibility - -State the technical responsibility and its maintenance boundary.[^source-id] - -# Current implementation - -Explain how the responsibility is implemented at the pinned revision. - -# Boundaries and ownership - -Name repositories, components, dependencies, and ownership transitions. - -# Data and control flow - -Trace material inputs, state changes, outputs, and asynchronous boundaries. - -# Contracts and invariants - -State interfaces, schemas, protocols, invariants, and compatibility constraints. - -# Failure and operational behavior - -Explain failure modes, recovery, observability, security, and operational -constraints, or state why a concern does not apply. - -# Verification - -Link the source, tests, runtime checks, and limitations that support this -document. - -# Product knowledge - -Link the stakeholder-facing product concepts that explain why this -implementation exists. Do not duplicate their product explanation. - -# Relationships - -Link the parent Area and related engineering concepts. Mirror material -semantic links in `x-wf.relations`. - -[^source-id]: Direct pinned source evidence. - -Before changing `status` to `stable`, invoke `verify-knowledge-quality`, then -run `wfctl knowledge hash --concept knowledge/.../<concept>.md`. Replace -`x-wf.quality` with a passed receipt containing `by`, `at`, `content_hash`, and -all required checks. Add a current `verified` event with the same hash. diff --git a/.claude/skills/curate-engineering-knowledge/references/engineering-writing-contract.md b/.claude/skills/curate-engineering-knowledge/references/engineering-writing-contract.md deleted file mode 100644 index 2488d36..0000000 --- a/.claude/skills/curate-engineering-knowledge/references/engineering-writing-contract.md +++ /dev/null @@ -1,51 +0,0 @@ -# Engineering writing contract - -## Reader contract - -Write for engineers and operators who already understand the product concept -or can follow its link. Let them answer: - -1. What technical responsibility does this surface own? -2. Where and how is it implemented at the pinned revision? -3. How do data and control move through it? -4. Which contracts, invariants, and ownership boundaries apply? -5. How does it fail and how is it operated? -6. What evidence verifies the claims? - -## Separation rules - -- Link product meaning; do not restate it as technical prose. -- Never infer accepted intent, correctness, or rationale from code alone. -- Keep repository and symbol details out of product documents. -- Keep implementation detail here only when it helps understand, change, - operate, or verify the system. -- State uncertainty and drift explicitly. -- Separate current implementation from historical implementation and rejected - alternatives. - -## Required sections - -- `Responsibility` -- `Current implementation` -- `Boundaries and ownership` -- `Data and control flow` -- `Contracts and invariants` -- `Failure and operational behavior` -- `Verification` -- `Product knowledge` -- `Relationships` - -Use `Not applicable` with a reason when a section genuinely does not apply. - -## Method basis - -- C4 uses explicit abstraction levels for different audiences: - https://c4model.com/introduction -- arc42 separates stakeholder goals from hierarchical technical building - blocks: - https://docs.arc42.org/section-1/ -- Spec Kit separates product what/why from implementation how: - https://github.github.com/spec-kit/reference/agentic-sdd.html -- Architecture Decision Records preserve rationale and supersession separately - from current implementation: - https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions diff --git a/.claude/skills/curate-product-knowledge/SKILL.md b/.claude/skills/curate-product-knowledge/SKILL.md deleted file mode 100644 index 58a0062..0000000 --- a/.claude/skills/curate-product-knowledge/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: curate-product-knowledge -description: Author or materially update stakeholder-facing current product knowledge after its claims have independent authority. Use when an approved change, reviewed reconstruction, confirmed intake candidate, source audit, or explicit maintainer decision is ready to create or correct an Area, capability, use case, product flow, domain concept, product rule, vision statement, delivery summary, or evolution summary. Do not use for ordinary explanation, discovery, brainstorming, review-only requests, or unverified raw ideas. Separate accepted intent from observed delivery and keep implementation details in linked engineering documents. ---- - -# Curate Product Knowledge - -Write the product view of verified project truth. Make it understandable to a -client or product manager without requiring source code, API, architecture, or -repository knowledge. - -Read [the product writing contract](references/product-writing-contract.md) -before authoring or materially rewriting a product document. Use -[the product concept template](assets/product-concept.md) for a new concept. - -## Establish authority - -1. Work from the knowledge root and identify the primary Area. -2. Read the relevant Area index, product concepts, current decisions, and - linked engineering concepts in full. -3. Separate: - - accepted intent and product meaning; - - currently observed delivery; - - alignment or drift between them; - - planned, rejected, superseded, and unknown claims. -4. Require explicit maintainer authority for intent, product meaning, rules, - normative ownership, and product decisions. -5. Require pinned source and fresh checks for delivery claims. Invoke - `analyze-with-graphify` in every relevant exact leaf before direct source - and test inspection. -6. Never use raw, intake, search results, a compiled graph, or agent prose as - authority. - -## Author the product view - -1. Declare `view: product`, `purpose: current-behavior`, and include - `stakeholder` in `audience`. -2. State the current answer first. Explain what the product provides, who it - serves, observable behavior, rules, outcomes, boundaries, exceptions, - delivery state, examples, and meaningful evolution. -3. Use the vocabulary a domain expert or client would use. Explain necessary - domain terms on first use. - For a `Domain Concept`, explicitly record the canonical term, concise - definition, contextual boundary, accepted aliases, and names to avoid. - During unresolved discussion, keep proposed terms in the active change - record rather than silently changing current vocabulary. -4. Describe outcomes and behavior, not classes, functions, endpoints, schemas, - storage, messages, packages, repositories, or source paths. -5. Keep `Engineering details` link-only. Put technical explanations in a - document authored with `curate-engineering-knowledge`. -6. Use present tense only for behavior supported by the declared delivery - state. Say plainly when a capability is absent, partial, retired, unknown, - or accepted but not yet available. Never present planned or uncertain - behavior as currently available. -7. Preserve material exceptions and conditions. Plain language may simplify - wording but must not simplify away meaning. -8. Keep current truth at one stable path. When a decision changes, update the - current product explanation and link the decision lineage; do not copy the - whole Area into version folders. -9. Attribute every material claim to an authoritative source with matching - source IDs and footnotes. Do not expose machine-local paths. - -## Area indexes - -Treat `knowledge/areas/<area>/index.md` as the primary stakeholder page for an -Area. Use the Area template owned by `curate-project-knowledge`. Keep it -product-first and bounded: - -- summarize rather than flatten every child document; -- link capabilities, use cases, rules, current decisions, and evolution; -- show delivery honestly; -- keep `Engineering details` as links with short nontechnical labels. - -The path a page occupies is where it will live, whichever route it takes to get -there. Work from a change bundle writes it under that bundle's `promotion/` -directory at exactly that path, and the maintainer's word is what copies it into -`knowledge/`. Work from a reconstruction or intake case writes it into -`knowledge/` directly, because that promotion is what its closure waits for. - -## Verify before stable - -1. Invoke `verify-knowledge-quality` after the substantive body is complete. -2. Resolve every failed or uncertain rubric item. -3. Run `wfctl knowledge hash --concept <path>` and bind both the semantic - quality receipt and normal verification to that content hash. -4. Use `status: stable` only after the quality receipt is current, all - authority requirements pass, and normative claims have human verification. -5. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update`. -6. Do not report completion while any gate fails. diff --git a/.claude/skills/curate-product-knowledge/agents/openai.yaml b/.claude/skills/curate-product-knowledge/agents/openai.yaml deleted file mode 100644 index 0a53d11..0000000 --- a/.claude/skills/curate-product-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Curate Product Knowledge" - short_description: "Write verified stakeholder-facing product knowledge" - default_prompt: "Use $curate-product-knowledge to author verified current product behavior and canonical domain language for nontechnical stakeholders." diff --git a/.claude/skills/curate-product-knowledge/assets/product-concept.md b/.claude/skills/curate-product-knowledge/assets/product-concept.md deleted file mode 100644 index d2fc09b..0000000 --- a/.claude/skills/curate-product-knowledge/assets/product-concept.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -type: "<Product Capability|Product Rule|Use Case|Product Flow|Domain Concept>" -title: "<human product title>" -description: "<one-sentence current product meaning>" -status: draft -view: product -purpose: current-behavior -audience: - - stakeholder - - maintainer - - domain-expert -area: "<primary-area>" -capabilities: [] -authority: - - product-meaning - - implementation -generated: - by: "<producer>/<version>" - at: "<ISO-8601>" -verified: [] -realization: - intent: "<accepted|superseded>" - delivery: "<absent|partial|implemented|verified|retired|unknown>" - alignment: "<aligned|drifted|unknown>" - assessed_at: "<ISO-8601>" -x-wf: - relations: [] - quality: - status: pending -sources: - - id: "<maintainer-source-id>" - kind: maintainer-decision - resource: "<project-change or project-reconstruction decision>" - title: "<reviewed product authority>" - author: "human:<reviewer-id>" - - id: "<delivery-source-id>" - kind: source-code - resource: "git:<repository>@<40-character-commit>#<path>[:<symbol>]" - title: "<pinned delivery evidence>" ---- - -# What this provides - -Explain the recognizable product outcome and why it matters.[^maintainer-source-id] - -# Who it serves - -Name the people, roles, or neighboring capabilities that rely on it. - -# Domain language - -Define terms introduced or owned by this concept. For a `Domain Concept`, -state the canonical term, its contextual boundary, accepted aliases, and names -to avoid. Otherwise state `No new terms` when the document introduces none. - -# Current behavior - -Explain what happens now in observable product terms. Keep accepted intent and -observed delivery distinct when they differ.[^delivery-source-id] - -# Rules and outcomes - -State the decisions, state changes, and outcomes that govern the behavior. - -# Boundaries and exceptions - -Preserve material limits, conditions, exceptions, and explicit non-goals. - -# Delivery - -Say plainly whether the behavior is available, partial, absent, retired, or -uncertain, and what that means for the reader. - -# Examples - -Give one or more concrete domain examples. Do not use code or API examples. - -# Evolution - -Summarize only meaningful changes needed to understand the current behavior. -Link full decision records for rationale and lineage. - -# Related knowledge - -Link the parent Area, related capabilities, rules, use cases, flows, and -current decisions. Mirror material semantic links in `x-wf.relations`. - -# Engineering details - -Link separately authored engineering documents. Do not summarize their -implementation here. - -[^maintainer-source-id]: Explicit reviewed product authority. -[^delivery-source-id]: Direct pinned evidence for current delivery. - -Before changing `status` to `stable`, invoke `verify-knowledge-quality`, then -run `wfctl knowledge hash --concept knowledge/.../<concept>.md`. Replace -`x-wf.quality` with a passed receipt containing `by`, `at`, `content_hash`, and -all required checks. Add a current `verified` event with the same hash. diff --git a/.claude/skills/curate-product-knowledge/references/product-writing-contract.md b/.claude/skills/curate-product-knowledge/references/product-writing-contract.md deleted file mode 100644 index c05d9d0..0000000 --- a/.claude/skills/curate-product-knowledge/references/product-writing-contract.md +++ /dev/null @@ -1,87 +0,0 @@ -# Product writing contract - -## Reader contract - -Write for a product manager, client, maintainer, or domain expert who wants to -understand what the product does without learning how the software is built. -The reader should be able to answer: - -1. What does this provide and why does it matter? -2. Who uses or depends on it? -3. Which domain terms does it own, and what do they mean here? -4. What happens now in observable product terms? -5. Which rules, outcomes, boundaries, and exceptions apply? -6. Is it available, partial, absent, retired, or uncertain? -7. What changed materially and where can the rationale be found? - -This is neither end-user help nor a PRD. It is the stakeholder view of current, -verified product knowledge. Proposed behavior stays in active change records. - -## Language rules - -- Lead with the current answer. -- Prefer short concrete sentences and active voice. -- Use domain language; define necessary terms on first use. -- Reuse canonical Area terminology. Record aliases and discourage overloaded - names rather than silently switching vocabulary. -- Describe outcomes, choices, state changes, and visible consequences. -- Preserve conditions, exceptions, and non-goals. -- Replace internal identifiers with human names. -- Do not include code fences, inline code, API routes, source paths, data - schemas, class or function names, storage mechanisms, package names, or - protocol details. -- Do not say "the system" when the specific product actor or capability is - known. -- Do not call planned behavior current. Pair every present-tense delivery claim - with evidence and an explicit realization state. - -## Required sections - -- `What this provides` -- `Who it serves` -- `Domain language` -- `Current behavior` -- `Rules and outcomes` -- `Boundaries and exceptions` -- `Delivery` -- `Examples` -- `Evolution` -- `Related knowledge` -- `Engineering details` - -Use `Not applicable` with a short reason rather than deleting a section whose -absence could hide an unexamined concern. `Engineering details` contains links -only; it never summarizes implementation. - -## Abstraction test - -Fail the product view when any of these are true: - -- a stakeholder needs engineering knowledge to understand the main answer; -- replacing an implementation would require rewriting product behavior even - though the behavior did not change; -- technical nouns outnumber product or domain nouns; -- an important exception disappeared during simplification; -- the text implies delivery that the realization state or evidence does not - support; -- history overwhelms the current answer; -- a raw candidate or agent inference appears as authority. - -## Method basis - -The contract combines established boundaries rather than inventing one prose -style: - -- Diátaxis separates documentation by reader need: - https://diataxis.fr/ -- Spec Kit keeps product specification focused on what and why, with technical - how in a separate plan: - https://github.github.com/spec-kit/reference/agentic-sdd.html -- GOV.UK Content Design starts from user need and plain language: - https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/plan-manage-content/understand-content-design/ -- W3C clear-content guidance requires understandable language and structure: - https://www.w3.org/WAI/WCAG2/supplemental/objectives/o3-clear-content/ -- Cucumber BDD uses concrete examples to align business and technical meaning: - https://cucumber.io/docs/bdd/ -- Domain Storytelling validates domain behavior with domain experts: - https://domainstorytelling.org/ diff --git a/.claude/skills/curate-project-knowledge/SKILL.md b/.claude/skills/curate-project-knowledge/SKILL.md deleted file mode 100644 index 5fa7d33..0000000 --- a/.claude/skills/curate-project-knowledge/SKILL.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -name: curate-project-knowledge -description: Orchestrate promotion of independently verified claims into a workflow project's current OKF knowledge bundle. Use when a completed change, reconstruction case, raw-intake candidate, source audit, external source, or maintainer decision is ready to update durable product knowledge, engineering knowledge, decision lineage, Area navigation, or project history. Route stakeholder-facing content to curate-product-knowledge, technical realization to curate-engineering-knowledge, and every material document through verify-knowledge-quality. Never copy raw input into knowledge or let code define product intent. ---- - -# Curate Project Knowledge - -Coordinate the promotion boundary. Do not author product and engineering views -as one blended document. - -Read [the knowledge model](references/knowledge-model.md) before first-time -promotion, a new Area, or decision migration. - -## Accepted inputs - -A promotion may start from: - -- a closed change, whose pages are written under its own `promotion/` directory - and enter `knowledge/` only when the maintainer approves them; -- confirmed raw-intake candidate IDs with independent authority; -- confirmed source-first reconstruction candidate IDs; -- directly inspected source and tests at an exact Git revision; -- a primary external source; -- an explicit current maintainer decision. - -`raw/`, intake prose, QMD results, compiled graphs, Graphify output, and -agent-written summaries are never authority. - -## Route each durable claim - -Classify claims before selecting files: - -| Durable concern | View and owner | -| --- | --- | -| Current product purpose, capability, use case, flow, domain concept, rule, delivery summary, or Area evolution | Invoke `curate-product-knowledge` | -| Current implementation, architecture, repository ownership, contract, data/control flow, runtime, or operations | Invoke `curate-engineering-knowledge` | -| Durable choice that is hard to reverse, surprising without context, or resolves a real tradeoff | Use the decision template, then invoke `verify-knowledge-quality` | -| Primary external context | Use `view: reference`, preserve the primary source, then verify | -| Trusted unresolved current question | Use `view: uncertainty`, state missing authority, then verify | -| Proposed, rejected, or unadopted behavior | Keep outside `knowledge/` in changes, intake, or reconstruction | - -A significant product change normally updates both a product concept and its -linked engineering concept. A refactor with unchanged product behavior may -update engineering knowledge only. Do not create an empty counterpart merely -for symmetry. - -## Promotion procedure - -1. Work from the knowledge root. Require and invoke the native QMD skill, use - QMD to locate candidates, and read every selected document directly. -2. Identify the smallest primary Area. Use root product flows, architecture, - repositories, or decisions only when ownership genuinely crosses Areas. -3. Inspect existing lifecycle, provenance, realization, quality receipt, - verification, and decision lineage. -4. When implementation matters, invoke `analyze-with-graphify` in each exact - leaf, then inspect pinned source, tests, contracts, and runtime evidence. -5. Separate accepted intent, observed delivery, alignment, technical - realization, decision history, and uncertainty. Ask the maintainer only for - missing product authority, chronology, ownership, or a material decision. -6. Route product and engineering documents to their specialized skills. Never - reuse one body for both audiences. -7. For a new Area, create its product-facing index from - [the Area template](assets/area-index.md). Add only the typed sibling - collections needed now: `capabilities/`, `use-cases/`, `concepts/`, - `rules/`, `implementation/`, `decisions/`, and `log.md`. -8. Give every concept explicit `view`, `purpose`, and `audience`. Attribute - every material claim with an authoritative source ID and matching footnote. -9. Declare only material semantic edges in `x-wf.relations`; include a - meaningful context and a matching human-visible Markdown link. -10. Create a standalone decision only when the choice is hard to reverse, - surprising without context, or resolves a real tradeoff. Routine - implementation choices and minor wording changes belong in the owning - concept, change ledger, or Area evolution. Author durable decisions from - [the decision template](assets/decision.md). Keep one stable current - decision per lineage; make supersession reciprocal and acyclic. Preserve - approved predecessor bodies. -11. Update the product-facing Area index and its Evolution section when - current behavior changes. Append detailed chronology to the local log. -12. Invoke `verify-knowledge-quality` for every new or materially changed - concept. Do not self-approve a failed, uncertain, unread, or blocked check. -13. Finish content before hashing. Bind the passed quality receipt and normal - verification to the same `wfctl knowledge hash --concept <path>` output. - Normative claims require human verification. The hash reads frontmatter and - body rather than location, so a page drafted under a bundle's `promotion/` - directory is sealed where it is and the seal survives the copy. -14. Run `wfctl knowledge validate`, `wfctl knowledge build`, and `qmd update` for - a page that is already in `knowledge/`. Rebuild embeddings only when semantic - retrieval is needed. -15. Return to the originating workflow and record where each page is. A change - bundle keeps its pages under `promotion/` and records them with `wfctl work - promotion <id>`; nothing it wrote is in the corpus, and saying it is promoted - would claim a decision the maintainer has not made. Do not report completion - while any gate fails. - -## Where a page goes before it is knowledge - -Writing into `knowledge/` is the project speaking about itself, and it is a -maintainer decision. Two routes reach it, and they differ: - -| Source | Where the page is written | What puts it in `knowledge/` | -| --- | --- | --- | -| Change bundle | `changes/<state>/<id>/promotion/<destination>` | `wfctl work promote <id>`, on the maintainer's word | -| Reconstruction or intake case | `knowledge/` directly | the case's own promotion, which its closure is waiting for | - -The destination path is the same either way: write the draft at exactly the path -it will occupy, because that is the path it is copied to and the path every link -in it must resolve against. - -## Authority rules - -- Product intent, meaning, rules, and normative decisions require explicit - maintainer authority. -- Existing implementation requires pinned source and direct inspection. - Runtime claims require a fresh receipt when static code is insufficient. -- An absent delivery claim may use a reviewed whole-scope reconstruction - receipt because nonexistent code cannot be pinned. -- Architectural rationale, ownership, contracts, and policy require - maintainer review and contradiction checks against current implementation. -- Historical implementation requires pinned version-control history plus a - reviewed archive or reconstruction receipt. -- External facts require primary sources. - -A rejected proposal remains case-only by default. When repeated proposals -reveal a durable product boundary, ask the maintainer whether the boundary -itself is an accepted non-goal or negative rule. Only that explicitly accepted -rule may become current product knowledge or a decision; the rejected proposal -and its raw origin still remain outside the trust boundary. - -If a concept mixes authority classes, attribute each material claim to the -correct source. A quality receipt checks the writing and evidence match; it -does not create authority. diff --git a/.claude/skills/curate-project-knowledge/agents/openai.yaml b/.claude/skills/curate-project-knowledge/agents/openai.yaml deleted file mode 100644 index 6ad5907..0000000 --- a/.claude/skills/curate-project-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Curate Project Knowledge" - short_description: "Route verified claims into separate knowledge views" - default_prompt: "Use $curate-project-knowledge to route verified claims into product, engineering, and decision knowledge with quality review." diff --git a/.claude/skills/curate-project-knowledge/assets/area-index.md b/.claude/skills/curate-project-knowledge/assets/area-index.md deleted file mode 100644 index 86c1c9b..0000000 --- a/.claude/skills/curate-project-knowledge/assets/area-index.md +++ /dev/null @@ -1,61 +0,0 @@ -# <Area name> - -## Purpose - -Explain the recognizable responsibility or product functionality this Area -provides and why it exists. - -## Who it serves - -Name the people, roles, and neighboring Areas that depend on it. - -## Scope and boundaries - -State what belongs here, what does not, and the important boundaries with -neighboring Areas. - -## Current product behavior - -Summarize current observable behavior in stakeholder language. Keep accepted -intent, available delivery, partial or absent delivery, and known drift -distinct. Link detailed concepts instead of flattening them here. - -## Capabilities - -- Link each capability and state the product outcome it provides. - -## Use cases and flows - -- Link Area-local use cases. -- Link genuinely cross-Area flows from `knowledge/product/flows/`. - -## Rules and outcomes - -- Link material product rules and summarize only the outcome or constraint a - stakeholder needs to understand. - -## Delivery overview - -Summarize which accepted capabilities are available, partial, absent, retired, -or uncertain. Do not present implementation as accepted intent by default. - -## Current decisions - -- Link the stable current decision for each active Area-owned lineage. -- Reach deprecated predecessors through the current decision or Evolution. - -## Evolution - -Explain meaningful changes with enough context to understand what changed, -why, and what it affected. Link full decision records and the local `log.md`; -do not reduce history to bare supersession links. - -## Open questions - -- Link trusted current uncertainties. Raw candidates do not belong here. - -## Engineering details - -- Link Area-owned engineering concepts from `implementation/`. -- Link system-wide architecture and repository ownership where relevant. -- Keep this section link-only; technical explanations live in those documents. diff --git a/.claude/skills/curate-project-knowledge/assets/decision.md b/.claude/skills/curate-project-knowledge/assets/decision.md deleted file mode 100644 index 390516e..0000000 --- a/.claude/skills/curate-project-knowledge/assets/decision.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -type: Decision -title: "<decision title>" -description: "<current decision in one sentence>" -status: draft -view: decision -purpose: decision-history -audience: - - maintainer - - domain-expert - - engineer -decision_id: "<stable-lowercase-id>" -effective_at: "<ISO-8601>" -area: "<primary-area>" -capabilities: [] -authority: - - decision -generated: - by: "<producer>/<version>" - at: "<ISO-8601>" -verified: [] -supersedes: [] -superseded_by: "" -x-wf: - relations: [] - quality: - status: pending -sources: - - id: "<maintainer-decision-id>" - kind: maintainer-decision - resource: "project-change:<change-id>#decision" - title: "<reviewed decision>" - author: "human:<reviewer-id>" ---- - -# Context and problem - -Explain only the context needed to understand why this durable decision -exists.[^maintainer-decision-id] - -# Product decision - -State the exact stakeholder-visible choice and its boundaries in plain -language. - -# Rationale - -Explain why this option was chosen. - -# Alternatives - -List only alternatives that were seriously considered and why they were not -selected. State `None recorded` rather than inventing options. - -# Consequences and tradeoffs - -State benefits, costs, risks, constraints, and accepted tradeoffs. Link -engineering knowledge for technical consequences instead of embedding an -implementation walkthrough. - -# Affected knowledge - -Link every materially affected Area, capability, use case, rule, engineering -concept, or contract. Record non-lineage semantic edges in `x-wf.relations` -with the same target and meaningful context. - -# Transition and migration - -Explain the product transition and link separate engineering migration detail, -or state `Not applicable`. - -# Unresolved questions - -List only questions left open by this decision, or state `None`. - -# Evolution - -Explain what this changes relative to each predecessor. Use project-relative -`knowledge/...` paths in `supersedes` and `superseded_by`; keep reciprocal -links accurate and include matching links to every predecessor and successor. - -[^maintainer-decision-id]: Explicit maintainer approval in the linked decision receipt. - -Before changing `status` to `stable`, invoke `verify-knowledge-quality`, run -`wfctl knowledge hash --concept knowledge/.../<decision>.md`, and bind both -the passed quality receipt and human verification to the returned hash. diff --git a/.claude/skills/curate-project-knowledge/references/knowledge-model.md b/.claude/skills/curate-project-knowledge/references/knowledge-model.md deleted file mode 100644 index bbffae7..0000000 --- a/.claude/skills/curate-project-knowledge/references/knowledge-model.md +++ /dev/null @@ -1,247 +0,0 @@ -# Knowledge model - -## Surfaces and trust - -- `raw/`: append-oriented untrusted input; never evidence. -- `intake/`: Git-frozen raw review records; never current truth or an OKF source. -- `reconstruction/`: source-first baseline and audit receipts at exact revisions. -- `changes/active/`: proposed behavior, living execution agreements, and their checkpoints. -- `changes/archive/`: qualified historical changes and resolved capture receipts. -- `changes/inbox/`: pending non-authoritative captures awaiting triage. -- `knowledge/`: curated OKF v0.2 current knowledge and the default reading surface. -- source repositories: implementation authority at exact revisions. - -QMD retrieves Markdown but never proves truth or coverage. Compiled graphs are -disposable navigation. Graphify navigates source code but is not authority. -Every selected source is read directly. - -All lanes converge through one promotion gate: extract atomic candidates, -verify each against its proper authority, obtain maintainer adjudication for -normative or ambiguous meaning, update the smallest coherent current concepts, -verify quality, and validate the bundle. - -## One truth, multiple views - -Product and engineering documents are linked views of the same project, not -independent truths. - -### Product view - -Use for vision, Areas, capabilities, use cases, cross-Area product flows, -domain concepts, product rules, delivery summaries, and stakeholder evolution. -It answers what the product provides, who it serves, how it behaves now, which -rules and exceptions apply, and whether it is available. - -Declare: - -```yaml -view: product -purpose: current-behavior -audience: [stakeholder, maintainer, domain-expert] -``` - -Product bodies contain no implementation detail. Their `Engineering details` -section contains links only. - -### Engineering view - -Use for implementation, architecture, repositories, contracts, data and -control flow, runtime behavior, operations, and technical constraints. It -answers how current product behavior is realized and verified. - -Declare: - -```yaml -view: engineering -purpose: technical-realization -audience: [engineer, operator, maintainer] -``` - -Engineering documents link product meaning and never infer accepted intent -from code. - -### Decision, reference, and uncertainty views - -- Decisions use `view: decision`, `purpose: decision-history`, and include the - maintainer audience. -- Primary external context uses `view: reference`, - `purpose: external-context`. -- Trusted live questions use `view: uncertainty`, `purpose: open-question`. - -Proposed or rejected ideas do not use a current knowledge view. - -Create a standalone decision only when the choice is hard to reverse, -surprising without context, or resolves a real tradeoff. Keep routine local -choices in the owning concept, change ledger, or Area evolution. A repeated -rejection may expose a durable non-goal, but only an explicit maintainer -decision promotes that negative rule; rejected proposals remain case-only. - -## Human information architecture - -- `knowledge/index.md`: progressive project entry point. -- `vision/`: accepted project purpose, outcomes, principles, and non-goals. -- `areas/`: primary durable product or functional decomposition. -- `product/`: concise users, outcomes, Areas, and genuinely cross-Area flows. -- `architecture/`: cross-Area technical realization. -- `decisions/`: only genuinely cross-Area decision records. -- `repositories/`: technical ownership and integration boundaries. -- `uncertainties/`: trusted unresolved current questions. -- `references/`: primary external context. - -Each `areas/<area>/index.md` is the main stakeholder page. It links typed -sibling collections: - -```text -areas/<area>/ -├── index.md -├── capabilities/ # product -├── use-cases/ # product -├── concepts/ # product/domain -├── rules/ # product -├── implementation/ # engineering -├── decisions/ # decision history -└── log.md # local chronology -``` - -Do not nest implementation and decisions under a capability merely because -they support it. Link them. Subdivide a typed collection only when its own -size requires it. - -Use root collections only for honest project-wide ownership. When one Area is -primary, store the artifact there and link it from affected Areas. A bounded -context is a proven technical model and language boundary, not another word -for Area. - -## Product intent and realization - -Document lifecycle and product delivery are independent: - -```yaml -realization: - intent: accepted - delivery: verified - alignment: aligned - assessed_at: 2026-07-28T12:00:00Z -``` - -- Curated intent is `accepted` or `superseded`. -- Delivery is `absent`, `partial`, `implemented`, `verified`, `retired`, - `unknown`, or `not-applicable`. -- Alignment is `aligned`, `drifted`, `unknown`, or `not-applicable`. - -Concrete delivery requires implementation authority. Concrete alignment -requires both product and implementation authority. Code proves observed -delivery, never accepted intent or correctness. - -## Authored relations and compiled navigation - -Every concept declares `x-wf.relations`. Add only material relations, give -each a meaningful context, and repeat its target as a normal Markdown link. -Supported kinds are `supports`, `governed-by`, `implemented-by`, `depends-on`, -`affects`, `conflicts-with`, and `related-to`. - -Area ownership and decision lineage have dedicated metadata and generated -edges. Stable concepts remain reachable from `knowledge/index.md`. -`wfctl knowledge build` compiles these explicit statements into ignored -navigation artifacts; it infers no truth. - -## Strict profile over OKF - -Every concept requires: - -- explicit lifecycle, view, purpose, audience, generation, and authority; -- non-empty claim-level authoritative sources with matching footnotes; -- explicit authored relations and valid human-visible links; -- current verification for stable content; -- a current semantic quality receipt for stable content; -- human verification for normative authority; -- explicit deprecation destination or reason; -- no raw or intake reference. - -Path and view must agree: - -- product: `vision/`, `product/`, and Area `capabilities/`, `use-cases/`, - `concepts/`, or `rules/`; -- engineering: `architecture/`, `repositories/`, and Area `implementation/`; -- decision: root or Area `decisions/`; -- reference: `references/`; -- uncertainty: `uncertainties/`. - -The validator rejects code and implementation sections in product documents -and requires their stakeholder sections. It requires technical sections in -engineering documents. Deterministic checks cannot prove semantic truth, so -`verify-knowledge-quality` reads the full evidence and records a -content-hash-bound review. - -## Quality receipt - -After semantic review, record: - -```yaml -x-wf: - relations: [] - quality: - status: passed - by: workflow-agent/1 - at: 2026-07-28T12:00:00Z - content_hash: "<wfctl knowledge hash output>" - checks: - - factuality - - audience-fit - - abstraction - - completeness - - delivery-state - axes: - authority-truth: - status: passed - by: workflow-agent/1 - at: 2026-07-28T12:00:00Z - content_hash: "<same hash>" - reader-communication: - status: passed - by: workflow-agent/1 - at: 2026-07-28T12:00:00Z - content_hash: "<same hash>" -``` - -The material hash excludes `verified` and `x-wf.quality`, allowing both -receipts to bind the exact authored content without self-reference. Any other -material edit changes the hash and invalidates both receipts. The quality -receipt records a review; it creates no authority. Its two axes review -authority/truth and reader communication independently. - -## Authority by claim - -- Intent, product meaning, architecture rationale, ownership, contracts, - policy, and decisions require maintainer authority. -- Existing implementation requires pinned source code. -- Absent delivery may use a reviewed whole-scope reconstruction receipt. -- History requires pinned version-control evidence plus a reviewed archived - change or reconstruction receipt. -- External facts require primary sources. - -Pinned code resources use -`git:<repository>@<40-character-commit>#<path>[:<symbol>]`. -Reviewed changes use `project-change:<id>#<section>`. -Reviewed reconstruction decisions use -`project-reconstruction:<case-id>#<candidate-id>`. - -Authority is claim-specific. Repetition, recency, search rank, and agent -confidence do not create authority. - -## Current truth and evolution - -Keep current meaning at one stable path. A changed decision creates a successor -record and deprecates predecessors through reciprocal lineage. Do not version -whole Areas. - -The product Area index leads with current behavior. Its Evolution section -summarizes what changed, why, and what it affected. Full decision records keep -context, exact choice, rationale, alternatives, consequences, transition, open -questions, and lineage. Area logs provide local chronology without flattening -hundreds of decisions into one file. - -Canonical domain language belongs with the owning Area concept rather than a -flattened global glossary. Record the preferred term, definition, contextual -boundary, accepted aliases, and names to avoid. Proposed terminology stays in -the active change record until product authority accepts it. diff --git a/.claude/skills/error-handling/SKILL.md b/.claude/skills/error-handling/SKILL.md deleted file mode 100644 index 3e2282f..0000000 --- a/.claude/skills/error-handling/SKILL.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -name: error-handling -description: Use whenever work adds, changes, reviews, or debugs thrown errors, rejected promises, catch or finally blocks, retries, cancellation, fallbacks, recovery, error stacks, cause chains, lost call sites, async or RPC origins, source maps, global error listeners, React or route error boundaries, TanStack Query failure policy, incident reporting, expected domain outcomes, or decides which layer owns a failure. Trigger even when the request only mentions an unhandled rejection, swallowed error, duplicate report, misleading stack trace, crash fallback, mutation failure, background refetch failure, Sentry or OpenReplay exception capture, or whether an error should propagate. ---- - -# Error Handling - -Handle a failure once, at the narrowest boundary that can make the required -decision. Keep propagation, reporting, recovery, and user presentation separate -so that one failure does not become several logs, incidents, and messages. - -Error handling is a control-flow concern. Logging records diagnostic facts; -user feedback presents an outcome. Neither one automatically means the failure -was handled. - -## Trace The Existing Flow First - -Before changing an error path: - -1. Find where the value originates and whether it is thrown, rejected, or - returned as a typed result. -2. Trace every layer that catches, logs, reports, retries, converts, or presents - it. -3. Inspect global listeners, error boundaries, and TanStack Query cache - callbacks that may already observe it. -4. Identify the boundary that owns recovery and the boundary that owns user - feedback. -5. Preserve the repository's established reporter and provider integrations. - -Do not add a local `catch` until this trace proves what decision belongs there. - -## Classify The Outcome Before Handling It - -Do not treat every non-happy path as the same failure: - -- a successful value needs no error path; -- an expected domain outcome should remain a typed result when the contract - models it as one; -- cancellation or superseded work usually needs cleanup, not an incident; -- recoverable degradation may justify a warning and fallback; -- an operation failure may need local recovery, reporting, or both; -- an unrecoverable render or initialization failure needs a recovery boundary. - -Do not invent an `Error` subclass merely to represent a non-failure such as -`pending`, `queued`, or `requiresApproval`. Prefer the generated or -schema-derived discriminated result. Preserve an existing typed-error control -flow when the project deliberately uses one, but do not generalize it into a -new application-wide convention. - -## Give Each Boundary One Job - -Low-level transports, API functions, and reusable utilities normally preserve -and propagate failures. They do not choose toast copy, navigation, or a page -fallback. - -A boundary may catch when it can: - -- recover or select a fallback; -- retry with an explicit policy; -- translate a known domain outcome into another established contract; -- attach context required by an incident reporter; -- present feedback owned by that interaction; -- perform cleanup in `finally`; -- terminate propagation intentionally. - -If none applies, let the failure propagate. - -## Avoid Catch, Log, And Rethrow - -Do not catch only to log and rethrow: - -```ts -// Avoid: the caller, query cache, or global boundary may report it again. -try { - return await loadWorkspace(workspaceId) -} catch (error) { - log.error("Workspace load failed", error, { workspaceId }) - throw error -} -``` - -Prefer direct propagation: - -```ts -return loadWorkspace(workspaceId) -``` - -Catch and rethrow only when adding information that cannot be supplied at a -higher boundary. Prefer `cause` or the repository's established typed contract; -do not flatten the original value into a generic message. - -Never swallow silently. An intentionally ignored failure must be demonstrably -expected. Add a short comment or a `warn` with safe context when the reason is -not obvious. - -## Preserve The Failure Origin - -A `try`/`catch` does not erase an existing error stack by itself. The origin is -usually lost when code replaces the value with a new error, converts it to a -string, mutates its stack, reports only the catch location, or crosses an -async/process boundary without carrying diagnostic origin separately. - -- Rethrow the same value when no new contract is required. -- If a new error genuinely adds boundary meaning, preserve the received value - as `cause`; do not pretend the wrapper stack is the original failure stack. -- Never append consumer or call-site text to `error.stack`. Keep - `errorStack`, `cause`, and an optional `originStack` as separate facts. -- Capture an origin before starting work whose later stack cannot lead back - across a timer, event, Query execution, worker, native module, or RPC call. -- Report native or host failures in the runtime where the original error still - exists. Correlate runtimes with safe request or operation identifiers instead - of reconstructing an error in the receiving runtime. -- Treat source maps and symbolication as part of the production error path, not - optional build decoration. - -Read `references/trace-preservation.md` whenever changing catches, wrappers, -async scheduling, global reporting, native boundaries, or stack diagnostics. - -## Preserve Errors As Received - -Keep transport and domain error identity, status, code, details, and cause -available to the owner that understands them. - -Do not introduce: - -- a universal application error class; -- recursive error normalization or serialization; -- registries that must change for every new error type; -- message-string matching when a typed field exists; -- catch-all conversion merely to satisfy a local helper. - -Create an `Error` fallback only at a boundary that requires an actual `Error` -instance, such as an exception-reporting SDK or render boundary, and retain the -original value as `cause` when useful. - -An established privacy or redaction adapter may need a safe representation for -one specific sink. Keep it at that sink. If legacy code must produce a redacted -replacement `Error`, verify that it does not replace the only surviving failure -stack: retain the original locally when safe and carry its unchanged standard -stack explicitly in the sink-local error or record. Do not promote that -exception into a generic error conversion pipeline. - -## Make Incident Reporting Explicit - -Reporting means sending a failure to an incident or diagnostic backend. It is -not synonymous with logging or showing feedback. - -- Report once, at the boundary with the best safe diagnostic context. -- Keep provider SDKs behind the repository's reporting facade or - provider-owned logger transport. -- Do not turn every `logger.error()` into an incident unless the repository - explicitly defines the error level that way. When it does, reporting emits - one logger record and must not call the provider separately. -- Do not make `showError()` secretly report through a boolean option. -- Avoid reporting expected cancellation, validation failures, authorization - outcomes, or other explicitly handled states unless project policy requires - it. -- Keep identifiers and operational metadata; exclude secrets, credentials, - raw payloads, and unnecessary personal data. - -When both local logging and incident capture are needed, ensure they represent -one intentional flow rather than two independent observers reporting the same -failure. - -## Use Global Boundaries As Backstops - -Initialize global rejection and uncaught-error listeners once at the runtime -composition root. They catch failures that escaped normal ownership; they are -not a replacement for local recovery. - -Treat these listeners as terminal observers. Pass the supplied error or -rejection reason into the configured logger with automatic origin capture -disabled. When an incident provider is a logger transport, this one record must -fan out to both persistence and incident capture; do not call the provider -again from the listener. - -Use React, route, or application error boundaries to: - -- report otherwise-unhandled render failures; -- replace a broken subtree with stable fallback UI; -- offer an appropriate reset, retry, reload, or navigation action; -- isolate the smallest useful region when recovery can remain local. - -Do not use an error boundary for event-handler or awaited action failures that -the interaction boundary can handle directly. - -## Treat Retry As Product Behavior - -Retry only when the operation is safe to repeat and the policy is explicit. -Consider idempotency, attempt limits, delay, cancellation, offline behavior, -and whether the user should remain in control. - -Do not add retries merely to hide an unknown failure. Do not combine automatic -retry at several layers. One owner must decide when attempts stop and what -recovery becomes visible. - -## Coordinate TanStack Query Deliberately - -TanStack Query can observe a failure at the query or mutation function, cache, -hook, call site, and error boundary. Choose one technical reporting path and one -presentation owner. - -Read `references/tanstack-query.md` whenever work touches Query or Mutation -failure behavior, `mutateAsync`, cache callbacks, background errors, -`throwOnError`, retry, or duplicate reporting. - -## Keep User Presentation Separate - -After handling policy is decided, delegate presentation to **user-feedback**: - -- field or form errors; -- inline region errors; -- toasts, banners, dialogs, and alerts; -- success, warning, and informational outcomes; -- retry or recovery controls visible to the user. - -The same catch boundary may call the reporter and the feedback layer when it -truly owns both decisions, but the helpers themselves remain independent. - -## Verify The Result - -Before finishing: - -- trace one failure from origin to its terminal owner; -- confirm it is not logged or reported twice; -- confirm expected cancellation and domain outcomes are not incidents; -- confirm low-level code does not own product copy or UI; -- confirm the original typed error remains available where needed; -- confirm no catch, wrapper, reporter, or transport overwrites the original - error stack; -- confirm any async or cross-runtime origin was captured before the boundary and - kept separately; -- confirm production stack artifacts match the exact release or update; -- confirm retry has one owner and a stop condition; -- confirm global listeners and boundaries initialize once; -- confirm incident context contains no secrets; -- confirm the user receives one appropriate presentation, if any. - -## Related Skills - -- External transports, generated error contracts, and query or mutation - ownership → **api-integration**. -- Diagnostic records, child context, persistence, and telemetry transports → - **logging**. -- Toasts, inline messages, dialogs, fallbacks, and recovery copy → - **user-feedback**. -- Form validation and server field errors → **forms**. -- Component and async-region rendering → **components**. -- Native exception boundaries and privileged provider wiring → - **native-integration**. -- Placement of reporting modules and boundaries → **file-structure**. diff --git a/.claude/skills/error-handling/agents/openai.yaml b/.claude/skills/error-handling/agents/openai.yaml deleted file mode 100644 index 6ab0673..0000000 --- a/.claude/skills/error-handling/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Error Handling" - short_description: "Error ownership, reporting, and recovery" - default_prompt: "Use $error-handling to design or review error propagation, catch boundaries, reporting, and recovery behavior." diff --git a/.claude/skills/error-handling/references/tanstack-query.md b/.claude/skills/error-handling/references/tanstack-query.md deleted file mode 100644 index 278ee2c..0000000 --- a/.claude/skills/error-handling/references/tanstack-query.md +++ /dev/null @@ -1,195 +0,0 @@ -# TanStack Query Failure Flow - -Use this reference when changing query or mutation error propagation, -reporting, retry, feedback, or Error Boundary behavior. - -## Keep The Query Function Honest - -Let a query or mutation function reject with the original transport or domain -error. Do not resolve a failure as ordinary data and do not convert every error -into a generic application type. - -The API module owns transport behavior and canonical cache effects. It does not -own page-specific feedback. - -## Choose One Reporting Observer - -`QueryCache` and `MutationCache` callbacks can provide one application-wide -diagnostic observer: - -```ts -const queryClient = new QueryClient({ - queryCache: new QueryCache({ - onError(error, query) { - if (isExpectedCancellation(error)) return - - reportError(error, { - captureOrigin: false, - module: "query", - context: { - queryFamily: String(query.queryKey[0] ?? "unknown"), - }, - }) - }, - }), -}) -``` - -This is a policy option, not a mandatory pattern. Reporting every cache error -without classification creates noise from cancellation, background refetch, -offline behavior, and expected authorization or domain outcomes. - -`captureOrigin: false` is deliberate in a declarative Query observer: the -query's rejection stack is primary, while a newly captured cache-callback stack -only identifies observation. A Mutation observer may instead receive an -earlier `originStack` captured per execution as shown below. - -Do not serialize an entire query key, mutation variables, or transport payload -into reporting context. Select only the safe identifiers needed for diagnosis. - -When cache-level reporting exists, do not report the same failure again from a -component merely because it also presents feedback. - -### Do Not Forge A Combined Stack - -A Query or Mutation error stack usually identifies the transport or domain -failure but may not identify the component or interaction that started the -operation. Keep those facts separate. - -Do not mutate the shared error: - -```ts -// Avoid: retries, observers, and reporters now see a modified error object. -error.stack += `\n--- used in ---\n${callSite}` -``` - -If a consumer origin matters, capture it before the asynchronous execution and -pass it as diagnostic metadata: - -```ts -const origin = captureTraceOrigin() - -try { - await updateProject.mutateAsync(input) -} catch (error) { - reportError(error, { - originStack: origin.stack, - context: { projectId }, - }) -} -``` - -This call-site owner replaces cache-level reporting for that same failure; the -cache may still own retry and canonical mutation lifecycle. Do not create two -incident reports. - -`meta` is suitable for stable mutation ownership or a registration stack -captured when options or a hook are created. That identifies the consumer -definition, not necessarily the later `mutate()` or `mutateAsync()` invocation. -Name it honestly, for example `registrationStack`, and do not present it as the -exact invocation stack. - -If an application requires one global MutationCache reporter with the actual -`mutate()` or `mutateAsync()` invocation chain, capture synchronously in the -global `onMutate` callback and associate the origin with that Mutation instance: - -```ts -const mutationOrigins = new WeakMap<object, TraceOrigin>() - -const mutationCache = new MutationCache({ - onMutate(_variables, mutation) { - mutationOrigins.set(mutation, captureTraceOrigin()) - }, - onError(error, _variables, _onMutateResult, mutation) { - reportError(error, { - originStack: mutationOrigins.get(mutation)?.stack, - context: { - mutationFamily: String( - mutation.options.mutationKey?.[0] ?? "unknown", - ), - }, - }) - }, - onSettled( - _data, - _error, - _variables, - _onMutateResult, - mutation, - ) { - mutationOrigins.delete(mutation) - }, -}) -``` - -Capture at the start of `onMutate`, before returning or awaiting anything. In -current TanStack Query execution, that callback is entered from the imperative -mutation call before mutation work crosses its asynchronous boundary, so its -raw stack still includes the consumer chain. The WeakMap keeps concurrent -Mutation instances isolated and releases settled entries. - -Verify this behavior against the installed TanStack Query version because -callback signatures and execution details are library contracts that may -change. A restored or resumed persisted mutation has no live caller origin; do -not invent one. - -Do not put the origin in mutation variables, use one mutable "latest origin" -slot, mutate the error, or parse a fixed number of stack lines. - -## Separate Background Failure From Empty Failure - -A background refetch may fail while usable cached data remains visible. Do not -replace that data with a full error screen or emit a global toast automatically. -Choose a subtle stale or retry indication when the product needs one. - -An initial query with no usable data may render an inline error region with a -retry action. Follow the async rendering waterfall from **components** and the -presentation rules from **user-feedback**. - -## Understand Mutation Control Flow - -`mutateAsync()` returns a promise and rejects when `mutationFn` rejects. An -ordinary `try`/`catch` can therefore classify a submit or action failure: - -```ts -try { - await updateProject.mutateAsync({ projectId, name }) - showSuccess("Project updated") -} catch (error) { - showError(error, { - fallbackMessage: "Unable to update the project", - }) -} -``` - -`throwOnError` controls whether a stored mutation error is propagated during -render to an Error Boundary. It is not required for `mutateAsync()` to reject. - -The callback-based `mutate()` does not return an awaitable failure. Use its -callbacks when that style already owns the interaction; do not wrap it in -`try`/`catch` and expect the asynchronous error there. - -## Keep Lifecycle Ownership Stable - -- Keep canonical invalidation and cache updates in reusable mutation options - owned by **api-integration**. -- Keep interaction-specific success and failure feedback at the call site. -- Do not spread mutation options and silently replace their lifecycle - callbacks. -- Do not duplicate retry between TanStack Query, a transport interceptor, and - the component. -- Use Error Boundary propagation only for failures the local interaction - cannot or should not recover from. - -## Test The Policy - -Cover at least: - -- initial failure without data; -- background failure with cached data; -- expected cancellation; -- mutation rejection handled at the call site; -- repeated or concurrent mutations without origin cross-contamination; -- retry without repeated mutation of the same error stack; -- one technical report for one failure; -- retry exhaustion and recovery. diff --git a/.claude/skills/error-handling/references/trace-preservation.md b/.claude/skills/error-handling/references/trace-preservation.md deleted file mode 100644 index a975c17..0000000 --- a/.claude/skills/error-handling/references/trace-preservation.md +++ /dev/null @@ -1,180 +0,0 @@ -# Error Trace Preservation - -Use this reference when a catch, wrapper, async boundary, reporter, or native -call makes a failure point to infrastructure instead of its real origin. - -## Contents - -- [Catch Is Not The Loss](#catch-is-not-the-loss) -- [Propagation And Wrapping](#propagation-and-wrapping) -- [Async Origins](#async-origins) -- [Process And Native Boundaries](#process-and-native-boundaries) -- [Reporting](#reporting) -- [Framework Boundaries](#framework-boundaries) -- [Verification](#verification) - -## Catch Is Not The Loss - -An existing `Error` normally keeps the stack captured when it was created. -Merely entering a `catch` does not replace it: - -```ts -try { - return await loadProject() -} catch (error) { - throw error -} -``` - -This catch is still unnecessary when it makes no decision, but it does not by -itself reset an `Error` stack. - -The destructive versions are: - -```ts -catch (error) { - throw new Error(String(error)) -} - -catch (error) { - reportError(new Error("Project failed")) -} - -catch (error) { - error.stack += `\n${new Error().stack}` -} -``` - -They replace identity, discard typed fields or cause, or corrupt the original -stack. - -## Propagation And Wrapping - -Propagate the received value unchanged unless a boundary owns a real contract -translation. - -When a wrapper adds essential meaning, retain the original: - -```ts -try { - await storage.open(projectId) -} catch (error) { - throw new Error("Opening project storage failed", { - cause: error, - }) -} -``` - -The wrapper stack and cause stack describe different layers. Do not concatenate -them into one string. Let the established incident provider represent the cause -chain when it supports it; otherwise retain the original cause locally and add -small safe context explicitly. - -Do not create a wrapper only to change wording for a log or user message. Logger -messages and user feedback can add context without replacing the failure. - -## Async Origins - -An error stack describes where the failure occurred. It may not show who -scheduled the work after a timer, event, queue, Query execution, worker, or RPC -boundary. - -Capture a supplemental origin synchronously before crossing that boundary: - -```ts -const origin = captureTraceOrigin() - -runInBackground() - .catch(error => { - reportError(error, { - originStack: origin.stack, - context: { operationId }, - }) - }) -``` - -Keep the origin outside the error object. Capture one origin per execution so -parallel work cannot overwrite another operation's diagnostic. - -Do not capture inside the later `catch` and label it as the operation origin. It -only identifies the observer. - -## Process And Native Boundaries - -Do not expect an `Error` instance to preserve class identity, non-enumerable -fields, cause, and stack through browser workers, Electron IPC, native modules, -or another RPC implementation. - -Choose ownership before transport: - -- report a host/native failure in the host/native runtime while the original - error exists there; -- report a renderer/JavaScript failure in that runtime before sending a - persistent log record; -- send minimal standard diagnostic strings only when another runtime must - persist or display them; -- use a request or operation identifier to correlate the two runtime records. - -Never recreate a received string as `new Error(receivedMessage)` and present its -receiver-side stack as the remote failure origin. - -## Reporting - -Pass the original error to the incident reporter whenever it accepts the value. -Attach safe module, operation, request, and supplemental origin information -through the provider's scope or context API. - -If a reporter requires an `Error` but the thrown value is not one, create a -fallback only at that reporter boundary: - -```ts -const reportable = - error instanceof Error - ? error - : new Error(message, { cause: error }) -``` - -That fallback stack identifies the conversion boundary. Do not describe it as -the original failure location. - -Avoid global `Error.prepareStackTrace` overrides and stack-string rewriting. -They are engine-specific and can interfere with source-map and incident -provider processing. - -An established redaction adapter is another replacement boundary. If it creates -a safe `Error` instance or record for one sink, preserve the received error in -the local flow when safe and copy its unchanged standard stack explicitly into -that sink-local diagnostic shape. A newly constructed redacted error otherwise -points to the sanitizer, which makes the privacy layer look like the failure -origin. - -## Framework Boundaries - -- TanStack Query cache callbacks are observers. Preserve the rejection and use - explicit ownership metadata; read `tanstack-query.md` for exact invocation - origins. -- Disable automatic logger-origin capture in observers that have no earlier - operation origin. Their stack is still available when intentionally needed, - but it must not be mislabeled as the caller. -- React error boundaries receive an error stack and a component stack. Preserve - both as separate diagnostic fields. -- Global `error` and `unhandledrejection` listeners are terminal backstops. Use - the supplied `Error` or rejection reason; do not replace it merely to add the - words "Unhandled error". -- A retry is another execution, not permission to append another section to the - same error stack. - -## Verification - -For each changed flow, record: - -1. where the original error is created; -2. which layer first catches it; -3. which layer owns recovery or reporting; -4. which discontinuity requires a supplemental origin; -5. which runtime retains the original error; -6. how production frames map to the exact shipped artifact. - -Force the same failure through retries and concurrent executions. Confirm that -the original stack remains byte-for-byte unchanged and each execution retains -its own origin. diff --git a/.claude/skills/explore-project-knowledge/SKILL.md b/.claude/skills/explore-project-knowledge/SKILL.md deleted file mode 100644 index 4e93f28..0000000 --- a/.claude/skills/explore-project-knowledge/SKILL.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -name: explore-project-knowledge -description: Guide a person through a project's current product knowledge without requiring them to know its Areas, capabilities, terminology, repository layout, document paths, or implementation. Use for broad newcomer questions such as what this project is, why it exists, what it can do today, what is accepted but unavailable or unknown, where to start, or what its main directions are; for follow-ups that explore one Area, capability, use case, flow, rule, delivery state, or product decision; and whenever a product manager, client, maintainer, or domain expert needs a progressive nontechnical explanation rather than knowledge authoring. Remain read-only, reveal detail gradually, and never turn a question into curation or source work without explicit need. ---- - -# Explore Project Knowledge - -Act as a product guide for someone who may not know what to ask yet. Discover -the available reading path yourself and reveal it gradually. - -Read [the exploration contract](references/exploration-contract.md) before the -first broad discovery request in a session. - -## Resolve the knowledge surface - -1. If the current repository has the `knowledge` profile, work from it. -2. If the current repository is a leaf, read `.workflow/config.json`, resolve - its configured knowledge repository, and answer from that repository. -3. Require and invoke the native QMD skill. Check `qmd status`, and when it - reports documents pending embedding run `qmd embed` first: without vectors - the search degrades to lexical BM25 over the newest material. Then search only - the `knowledge` collection. If the native skill, CLI, or project index is - unavailable, invoke `setup-workflow-environment`; do not substitute grep or - pretend discovery was complete. -4. Answer from the documents first. Do not run `wfctl check` or - `wfctl knowledge validate` merely to answer a question: exploration is the - least expensive path, and both commands are diagnostics, not reading. Run - `wfctl knowledge validate` only when a document you read looks internally - inconsistent, a link you need is broken, or the reader asks how trustworthy - the map is. When it does fail, give only the bounded trustworthy orientation - still supported by readable current documents, state that the map is - incomplete, and offer a separate repair or audit. -5. Start with `knowledge/index.md` and the reachable Area indexes. Use QMD and - `.workflow/current/knowledge-graph.json` to find candidate paths, then read - every selected Markdown document directly. The compiled graph is a - navigation cache: when a path it suggests does not exist, fall back to - direct reading rather than trusting the graph or rebuilding it mid-answer. -6. Treat curated knowledge as the answer surface, subject to its lifecycle, - provenance, verification, realization, and uncertainty. Retrieval results - and generated graphs are navigation, not evidence. - -## Choose the reader's current level - -- **Discovery:** The reader does not know the project. Explain its purpose, - intended audience, current product shape, major directions, delivery - overview, and important unknowns. -- **Area exploration:** Explain one product direction, the outcomes it owns, - major capabilities and flows, current delivery, governing rules, and useful - next branches. -- **Focused explanation:** Explain one capability, use case, flow, rule, or - product decision: current behavior first, then conditions, exceptions, - delivery, and meaningful evolution. -- **Technical or historical deep dive:** Hand routing back to - `operate-project-knowledge` so engineering realization or full decision - lineage remains a separately labeled answer. - -Do not force the reader to choose a level or use workflow terminology. Infer -the narrowest honest level from ordinary language and continue naturally. - -## Present progressive disclosure - -1. Lead with the current product answer, not file structure or methodology. -2. On discovery, present a compact project map rather than flattening every - concept. Group a large corpus into a manageable set of recognizable - directions. -3. For every item, distinguish verified/current, partial, accepted but absent, - retired, and unknown. Do not turn missing delivery into missing intent or - vice versa. Proposed plans are outside current knowledge; route an explicit - roadmap question through `operate-project-knowledge` and label it separately. -4. Use stakeholder and domain language. Explain an internal term before using - it as navigation. -5. End with three to five concrete follow-up directions derived from the - knowledge actually found. Let the reader choose what to open next. -6. Ask at most one question when a useful overview can still be given. Ask - earlier only when the request could refer to materially different projects - or product meanings. -7. Keep code, repositories, paths, schemas, APIs, workflow commands, source - metadata, and quality machinery out of the answer unless the reader asks. - -## Stay read-only - -- Do not create or edit knowledge, changes, intake, reconstruction, indexes, or - source code merely because the reader asked a question. -- Do not invoke curation or quality verification for an explanation alone. -- If current knowledge is missing, stale, contradictory, or too weak to answer, - explain the exact product-level limitation and offer the appropriate next - action: knowledge audit, source verification, reconstruction, raw intake, or - maintainer adjudication. -- Perform that action only when the user asks to continue or when their - original request explicitly required current verification or repair. -- If exploration exposes a likely documentation defect, report it separately; - do not silently repair it. - -When available, return control to `operate-project-knowledge` whenever the -request changes from understanding into auditing, verification, authoring, or -decision-making. From a leaf, follow the leaf workflow when the request becomes -implementation. diff --git a/.claude/skills/explore-project-knowledge/agents/openai.yaml b/.claude/skills/explore-project-knowledge/agents/openai.yaml deleted file mode 100644 index 6e08490..0000000 --- a/.claude/skills/explore-project-knowledge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Explore Project Knowledge" - short_description: "Guide people through current project knowledge" - default_prompt: "Use $explore-project-knowledge to help me understand this project from a product perspective." diff --git a/.claude/skills/explore-project-knowledge/references/exploration-contract.md b/.claude/skills/explore-project-knowledge/references/exploration-contract.md deleted file mode 100644 index e383a7a..0000000 --- a/.claude/skills/explore-project-knowledge/references/exploration-contract.md +++ /dev/null @@ -1,77 +0,0 @@ -# Product exploration contract - -## Reader assumption - -Assume the reader may know only that a project exists. They are not expected to -know its taxonomy, feature names, implementation, history, or the wording used -inside the knowledge base. - -The agent owns discovery. The reader owns curiosity and product authority. - -## Response levels - -### Discovery - -Answer: - -1. What is this project for? -2. Who benefits from it? -3. What are its major product directions? -4. What works today? -5. What is partial, accepted but absent, retired, or unknown? -6. Where could the reader go next? - -Keep the first response scannable. Prefer a short orientation plus three to -five meaningful branches over an exhaustive catalog. - -### Area exploration - -Answer: - -1. What outcome does this direction own? -2. Who uses or depends on it? -3. Which major capabilities and flows belong to it? -4. Which rules and boundaries shape it? -5. What is its current delivery state? -6. Which questions or decisions remain open? - -### Focused explanation - -Lead with current behavior. Then explain rules, exceptions, delivery, examples, -and only the evolution necessary to understand the present. - -## Conversation behavior - -- A broad question receives a useful answer before any clarifying question. -- Suggested follow-ups use names and descriptions the reader has just seen. -- Each follow-up narrows one level unless the reader asks to compare multiple - directions. -- Do not expose document paths, metadata, retrieval queries, or agent workflow - in the normal answer. -- Do not offer an engineering deep dive as the only next step. Keep product - exploration useful to nontechnical readers. -- Do not create knowledge while answering. A discovered gap becomes an - explicit optional next action. -- Do not search proposals or active changes during ordinary current-product - discovery. A roadmap question is a separate route and must remain labeled as - future work. - -## Failure conditions - -Fail the exploration when it: - -- asks the reader to name an Area or capability before showing what exists; -- starts with code, architecture, repositories, or file navigation; -- presents a flat inventory with no product hierarchy; -- hides delivery or uncertainty; -- treats planned or accepted-but-absent behavior as available; -- overwhelms the first answer with every rule and historical decision; -- modifies project state without a separate request; -- invents a complete project map from sparse or unverified knowledge. - -## Evaluation discipline - -Test discovery with natural prompts that do not reveal the expected taxonomy or -rubric. Keep assertions hidden from the tested agent. Test focused conformance -separately only after the exploration itself has surfaced a real Area or -capability name. diff --git a/.claude/skills/file-structure/SKILL.md b/.claude/skills/file-structure/SKILL.md deleted file mode 100644 index d4dd483..0000000 --- a/.claude/skills/file-structure/SKILL.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: file-structure -description: Use whenever work creates, moves, renames, deletes, splits, merges, or relocates files or directories; introduces a module or folder; adds or expands barrel files; changes which files are public or private through entrypoints or package exports; or requires deciding where code should live. Do not use for content-only edits that preserve existing file placement. ---- - -# File Structure - -Keep the source tree aligned with real ownership. Apply this skill before making -structural changes, not after files have already been placed. - -## Inspect Before Placing - -Read the affected tree before deciding where anything belongs: - -1. Find the nearest existing files with the same responsibility. -2. Inspect local naming, co-location, entrypoint, test, and export conventions. -3. Trace current consumers and package exports before moving or hiding a file. -4. Prefer the repository's coherent existing convention. Do not introduce a - parallel structure merely because another project uses it. -5. If nearby conventions conflict, follow the boundary that best matches actual - ownership and keep the change scoped. Do not reorganize unrelated code. - -## Apply Recursive Encapsulation - -Treat every directory that represents a module as the same recursive structure: - -- its entrypoint is the public boundary seen by consumers outside that directory; -- files owned only by that module live inside the directory, behind the boundary; -- an owned child may become a module with its own entrypoint and private - implementation; -- repeat the same public-outside, private-inside rule at every depth. - -This is about visibility and ownership, not file kind. Apply it equally to -components, hooks, schemas, services, adapters, formatters, fixtures, tests, -state, and any other implementation. - -In this model, consumers sit outside or above the boundary they consume, while -owned implementation goes inward or below it. "Above" and "below" describe the -ownership tree; they do not require every consumer to be located in a literal -parent directory. - -Place every file at the narrowest boundary that contains all current consumers: - -- one file or module only: keep it inside that owner's boundary; -- several files in one feature: keep it at that feature boundary; -- sibling features or pages: place it at their nearest shared owner; -- unrelated features or packages: promote it only when multiple real consumers - require the same contract or behavior. - -Do not promote code for hypothetical reuse. The second real use is evidence to -reconsider placement, not an automatic reason to create a global abstraction. -When consumers change, move the file to their new narrowest common owner instead -of exporting a private path merely to avoid relocation. - -Keep supporting files near the module they verify or support unless the -repository has a stronger established convention. - -## Name Child Folders from Their Context - -Name an owning module by its capability, domain, or responsibility. Inside that -owner, use the conventional role folder that best explains the children: -`components/`, `hooks/`, `schemas/`, `services/`, `adapters/`, `formatters/`, -`fixtures/`, `tests/`, or the repository's established equivalent. - -These names are contextual, not global categories. `Feature/components/` means -components private to Feature; `Feature/hooks/` means hooks private to Feature. -Do not lift them into a broad root-level dump merely because several files share -the same technical kind. - -Avoid ambiguous catch-all locations such as `misc/`, overly broad `helpers/`, -overly broad `utils/`, `common/`, `shared/`, or a global `types.ts` when their -contents do not form one coherent boundary. Prefer a precise contextual role, -direct co-location, or the actual capability name. - -Do not create a directory for a single leaf file unless the directory establishes -a real module boundary, owns private children, or follows a required repository -convention. Promote a growing leaf to a folder without changing its public import -surface when practical. - -## Separate Public Surface from Implementation - -Structure packages and substantial modules by visibility: - -- expose only intentional, stable entrypoints at the public boundary; -- use an `internal/` directory only when it represents a real package- or - application-level non-public boundary and the repository uses that model; -- organize `internal/` by capability rather than turning it into another dump; -- use each internal module's entrypoint as its local boundary; -- do not expose internal implementation again through package subpath exports; -- keep platform-specific entrypoints separate when consumers genuinely need - different runtime surfaces. - -Do not create `internal/` inside every owner. A file is already private when it -lives inside its owner's directory and is absent from that owner's public -entrypoint. Use the contextual role folders above, or keep a private leaf beside -the entrypoint. - -## Minimize Barrels - -Prefer direct imports. Do not add an `index.ts` to every directory merely because -the directory exists. - -Distinguish three different uses: - -- an implementation entrypoint such as `Component/index.tsx`, where the file - defines or composes the public component, is not a barrel; -- a small curated public surface at a real package or feature boundary may - re-export a few intentional capabilities; -- a convenience file that mechanically re-exports directory contents is a - barrel and should normally not exist. - -Allow a barrel only when the boundary is real, the exported set is small, -cohesive, stable, and immediately obvious, and consumers should not know the -internal paths. There is no useful numeric limit: aim for one curated surface per -real public module, not one barrel per folder. - -Inside private implementation, import the owning file directly. Never: - -- create chains where one barrel imports from or re-exports another barrel; -- use `export *` to sweep a directory into an API; -- re-export private files merely to shorten an import path; -- combine unrelated capabilities behind one convenience entrypoint; -- keep expanding a barrel after the origin and ownership of its exports stop - being obvious. - -When barrels begin multiplying, replace convenience re-exports with direct -imports before the dependency graph becomes opaque or cyclic. - -## Recursive Structure Examples - -Choose the shape from the current owner and its children. These examples express -the same rule in different contexts; they are not mandatory folder names. - -### Component ownership - -```text -Workspace/ -├── index.tsx -├── hooks/ -│ └── use-workspace-state.ts -└── components/ - ├── Canvas.tsx - └── Toolbar/ - ├── index.tsx - ├── hooks/ - │ └── use-toolbar-shortcuts.ts - └── components/ - └── ToolbarAction.tsx -``` - -`use-workspace-state.ts` is shared by Workspace children. The shortcut hook and -action component are private to Toolbar and therefore live inside Toolbar. - -### Composed hook - -```text -hooks/ -└── use-selection/ - ├── index.ts - └── hooks/ - ├── use-keyboard-selection.ts - └── use-pointer-selection.ts -``` - -The surrounding feature imports `use-selection/index.ts`. Its composing hooks -stay behind that hook module's boundary. - -### Recursive feature ownership - -```text -FileUpload/ -├── index.tsx -├── components/ -│ ├── Dropzone/ -│ │ ├── index.tsx -│ │ └── hooks/ -│ │ └── use-drag-state.ts -│ └── UploadProgress.tsx -├── hooks/ -│ ├── use-upload-progress.ts -│ └── use-upload-queue.ts -└── validators/ - ├── file-size.ts - └── file-type.ts -``` - -The root `hooks/` and `validators/` contain behavior shared inside FileUpload. -`use-drag-state.ts` belongs only to Dropzone, so it moves into that component's -own boundary. The same rule therefore repeats inside the feature without an -undifferentiated `internal/`. - -### Single private leaf - -```text -Summary/ -├── index.tsx -└── format-total.ts -``` - -Do not create `internal/`, `helpers/`, or `formatters/` for one private leaf -unless local convention requires it. Direct co-location already communicates -ownership. - -### Real package-level internal boundary - -```text -package/src/ -├── index.ts -├── native.ts -└── internal/ - ├── protocol/ - │ └── index.ts - └── runtime/ - └── index.ts -``` - -Here `internal/` is meaningful: package consumers may import only the declared -public entrypoints, while several non-public capabilities remain package-owned. - -Outsiders import the public module, not its private children. Do not expose an -internal file merely because another internal file needs it; place both under -their narrowest common owner. - -When any leaf gains private children, promote it to a folder with an entrypoint -and continue the same structure recursively. - -## Defer Route And Page Trees To Routing - -When a structural change creates, moves, or reorganizes route branches, page -boundaries, route declarations, or route-tree assembly, apply **routing** as the -authoritative structure. Use this skill recursively for the non-routing -implementation inside the page or layout boundary selected by **routing**. - -## Make Structural Changes Complete - -When moving, renaming, splitting, merging, or deleting files: - -1. Update all imports, aliases, entrypoints, package exports, tests, fixtures, - tooling configuration, and generated registries that reference the old path. -2. Move the implementation; do not leave duplicate old and new copies unless a - deliberate compatibility layer is required. -3. Preserve public import paths when the public contract is not meant to change. -4. Remove obsolete empty structure only when it is fully owned by the change. -5. Search for the old path and old exported names after the move. -6. Run the repository's focused structural verification, then its required - typecheck, lint, tests, and build as appropriate. diff --git a/.claude/skills/file-structure/agents/openai.yaml b/.claude/skills/file-structure/agents/openai.yaml deleted file mode 100644 index d1349ef..0000000 --- a/.claude/skills/file-structure/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "File Structure" - short_description: "Organize files by ownership and visibility" - default_prompt: "Use $file-structure to place files behind the narrowest correct module boundary." diff --git a/.claude/skills/forms/SKILL.md b/.claude/skills/forms/SKILL.md deleted file mode 100644 index f3761de..0000000 --- a/.claude/skills/forms/SKILL.md +++ /dev/null @@ -1,249 +0,0 @@ ---- -name: forms -description: Use whenever work creates, changes, reviews, or debugs a form, form field, input flow, validation schema, submit flow, create or edit form, server field error, react-hook-form register or Controller integration, or Zod form schema. Trigger even when the request only describes a product form such as sign-in, settings, search, filters, a dialog form, or an editable resource without naming the form libraries. ---- - -# Forms - -Build forms with `react-hook-form` + Zod and treat each form as a thin, -declarative boundary around rendered fields and submission. - -A field control may be: - -- an existing `Ui*` design primitive; -- a composition of several primitives; -- a product component with its own meaningful controlled or uncontrolled API. - -Inspect the repository's existing UI and product components before creating a -new control. Follow **components** for primitive reuse, product-component -ownership, and controlled/uncontrolled component contracts. - -## Schema and Types - -- Define the Zod schema close to the form unless the same form contract is - genuinely shared. Colocation beats premature extraction. -- The form schema mirrors the fields represented by the rendered form. It is - the readable inventory, validation contract, and value contract for that UI. -- Type form values with `z.infer<typeof formSchema>`. Never hand-maintain a - parallel `FormValues` interface. -- Wire validation through `zodResolver(formSchema)`. -- A form schema is not an API DTO and must not be shaped around one. Do not - create API types, proxy types, or DTO schemas inside the form. -- Give every stable field an explicit initial value through `defaultValues`. - Never use `undefined` as the value of a controlled field or `Controller`. -- Treat browser-managed file inputs separately. For conditionally mounted or - dynamically registered fields, deliberately choose registration, - unregistration, and default-value behavior instead of relying on omission. - -## Create And Edit Forms - -One form component may support both creation and editing by accepting an -optional existing entity: - -- entity present → edit mode; -- entity absent → create mode. - -Absence must mean create mode only. The owner must resolve loading, error, and -not-found states before mounting an edit form; do not temporarily render create -mode while an entity is still loading. - -Populate `defaultValues` explicitly from the entity: - -```ts -const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: entity?.name ?? "", - description: entity?.description ?? "", - }, -}); -``` - -Select only fields represented by the form. Do not spread the entire entity -into `defaultValues`. - -Treat these defaults as the initial snapshot for that form instance. When the -owner switches to a different entity, remount the form by identity: - -```tsx -<EntityForm key={entity?.id ?? "create"} entity={entity} /> -``` - -Key by stable identity, not by the entity object or all of its values. A -background query update for the same entity must not silently overwrite dirty -user input. - -Create both mutation hooks unconditionally, as required by React's hook rules, -and choose the operation inside `handleSubmit`: - -```ts -const onSubmit = handleSubmit(async (values) => { - if (entity) { - await updateMutation.mutateAsync({ - id: entity.id, - name: values.name, - description: values.description, - }); - return; - } - - await createMutation.mutateAsync({ - name: values.name, - description: values.description, - }); -}); -``` - -Do not use `useEffect` to copy entity data into form state. After a successful -update, call `reset()` with the committed form values only when the form remains -open and should establish a new pristine baseline. A form that closes or -navigates away does not need that reset. - -## Numeric and Coerced Inputs - -- HTML inputs usually produce strings. Decide explicitly what an empty value - means before converting it. -- Use `z.coerce.number()` only when its empty-string and coercion behavior is - correct for that field. Remember that `Number("")` is `0`. -- Prefer a deliberate `z.preprocess`, `register(..., { valueAsNumber: true })`, - or `setValueAs` when empty, optional, and invalid values need different - semantics. -- Apply the same discipline to dates, booleans, selects, and any other control - whose rendered value differs from its form value. - -## Validation - -- Per-field rules live on the field schema with a user-facing message - (`z.string().trim().min(1, "...")`). -- Cross-field rules use `.refine()` / `.superRefine()` and **must set `path`** so the - error attaches to the right field (e.g. confirm-password mismatch -> `path: ["confirmPassword"]`). - -## Submit and Pending State - -- Prefer one `handleSubmit(async (values) => { ... })` as the complete - form-submission flow. Keep request preparation, `mutateAsync`, field-error - handling, and success UX readable in that handler. -- Call the typed mutation with an explicit object built directly from form - values: - -```ts -const onSubmit = handleSubmit(async (values) => { - await mutation.mutateAsync({ - email: values.email, - password: values.password, - }); -}); -``` - -The mutation's typed parameter is the API contract and verifies the object. -Do not create form-to-DTO mappers, conversion helpers, proxy types, or local API -schemas. Form-only fields simply do not appear in the mutation call. - -- Use `mutateAsync` so the submit handler can await the operation and express - success and failure in normal control flow. -- `mutateAsync` rejects when the mutation function rejects, so an ordinary - `try`/`catch` handles submit failures without `throwOnError`. That option - controls render-phase propagation to an Error Boundary; do not enable it just - to make form error handling work. If the mutation function resolves an error - as a successful value or otherwise swallows it, correct or follow the - established contract in **api-integration** rather than compensating inside - the form. -- Compute one pending flag: - `isPending = formState.isSubmitting || mutation.isPending`. -- Drive duplicate-submission prevention and the submit control's pending - presentation from that flag. -- Keep canonical invalidation and API-wide mutation behavior inside the API - module's `mutationOptions`. See **api-integration**. -- Prefer local success UX directly after the awaited mutation: show feedback, - `reset()`, navigate, or close the form. -- Do not spread canonical mutation options and then overwrite their - `onSuccess` or `onError` callbacks in the form. - -## register vs Controller - -- **`register()` by default.** Native-input `Ui*` wrappers (`UiInput`, `UiTextarea`) - expose a native `value`/`onChange`/`ref` contract and bind directly: `{...register("email")}`. -- **`Controller` only when a component lacks a compatible native input - contract.** Custom primitives such as selects, switches, checkboxes, radio - groups, and segmented controls commonly need it. -- Product components may also act as fields. When their state must be owned by - the form, compose `Controller` with the component's controlled API - (`value`/`onValueChange` or its semantic equivalent). Do not move RHF into the - product component merely to make it usable by a form. -- Compose react-hook-form's `<Controller>` directly with the existing primitive - or product component at the field call site. -- Never create or reuse `ControlledUi{X}` components, `controlled.tsx` modules, or - reusable wrappers around `useController`. Keep RHF ownership visible in the form and - keep the `Ui*` layer independent of the form library. - -## Field Composition - -Prefer the established shadcn-style field composition when the project provides -it: a field container groups its label, control, optional description, and -validation error. The container owns field-level layout and invalid state; the -control keeps its own visual and interaction contract. - -This is a recommendation, not a mandatory component tree. Inspect existing -forms and the repository's UI primitives before choosing exact components, -names, props, or ordering. - -An adapted shadcn `Field` composition may look like: - -```tsx -<UiField data-invalid={!!errors.email}> - <UiFieldLabel htmlFor="email">Email</UiFieldLabel> - <UiInput - {...register("email")} - id="email" - type="email" - autoComplete="email" - aria-invalid={!!errors.email} - /> - {errors.email?.message && ( - <UiFieldError errors={[errors.email]} /> - )} -</UiField> -``` - -The names are illustrative. Use the project's equivalent rather than creating -these wrappers solely to match the example. Never restyle a base control to make -it fit a form; compose around it. - -## Server Field Errors - -- Catch submit failures in the `handleSubmit` callback when the form needs to - classify them. -- When the API returns a field-specific failure, attach it with - `setError(field, { message })`, then return. -- Detect the specific failure from the typed transport error (status/code/detail), not by - string-matching a generic message. Transport errors are thrown as received and preserve - their status/message/detail (owned by **api-integration**) — exploit that here instead of - flattening. -- General submit failures surface through the project's feedback flow, not - `setError`. -- Use one presentation path for each failure. Do not show a general mutation - error and then show the same failure again as a field error. - -## Cross-References - -- `Ui*` primitives, product fields, field-error presentation, and - controlled/uncontrolled component APIs → **components**. RHF `Controller` - composition remains owned here. -- `mutationOptions`, query/mutation key factories, and cache invalidation placement - -> **api-integration** (separate skill from state management). -- Submit success and failure feedback (`showError` / `showSuccess` or the - repository's equivalent) -> **user-feedback**. -- Submit failure propagation, reporting, retry, and Error Boundary policy -> - **error-handling**. -- Page/feature placement of the form module and where its files live -> **file-structure**. - -## Anti-Patterns - -- No `useEffect` to sync derived form state — derive in render or via watched values. -- No restyling base UI components to make a field fit; wrap them. -- No ternary expressions for conditional JSX. Use a simple logical condition - for one optional element and an ordered IIFE for multiple render branches. -- No form-to-DTO mapper layer, local API types, or duplicate API schemas. - -See `references/patterns.md` for terse, stack-specific patterns that should be -adapted to the repository's existing components and contracts. diff --git a/.claude/skills/forms/references/patterns.md b/.claude/skills/forms/references/patterns.md deleted file mode 100644 index cb39511..0000000 --- a/.claude/skills/forms/references/patterns.md +++ /dev/null @@ -1,172 +0,0 @@ -# Form Patterns - -Stack-specific, adaptable skeletons for react-hook-form, Zod, TanStack Query, -and a shadcn-style component system. Replace component and feedback names with -the repository's established equivalents. - -## (a) Basic Form + Mutation Submit with isPending - -```tsx -const formSchema = z.object({ - title: z.string().trim().min(1, "Title is required"), - quantity: z.number().positive("Must be greater than 0"), -}); -type FormValues = z.infer<typeof formSchema>; - -function ResourceForm() { - const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = - useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { title: "", quantity: 1 }, - }); - - const mutation = useMutation(createResourceMutationOptions()); - const isPending = isSubmitting || mutation.isPending; - - const onSubmit = handleSubmit(async (values) => { - // mutateAsync rejects when mutationFn rejects. throwOnError is only for - // render-phase Error Boundary propagation and is not required here. - try { - await mutation.mutateAsync({ - title: values.title, - quantity: values.quantity, - }); - - showSuccess("Saved"); - reset(); - } catch (error) { - showError(error, { fallbackMessage: "Unable to save" }); - } - }); - - const submitLabel = (() => { - if (isPending) return "Saving..."; - return "Save"; - })(); - - return ( - <form onSubmit={(e) => void onSubmit(e)}> - <UiInput - {...register("quantity", { valueAsNumber: true })} - type="number" - aria-invalid={!!errors.quantity} - /> - <UiButton type="submit" disabled={isPending}> - {submitLabel} - </UiButton> - </form> - ); -} -``` - -## (b) Cross-Field Refine (set path) - -```ts -const schema = z - .object({ - password: z.string().min(8), - confirmPassword: z.string().min(1, "Confirm your password"), - }) - .refine((v) => v.password === v.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], // attach error to the right field - }); -``` - -## (c) Build The Typed Mutation Parameter In handleSubmit - -```ts -const onSubmit = handleSubmit(async (values) => { - await signUpMutation.mutateAsync({ - email: values.email, - password: values.password, - }); -}); -``` - -`confirmPassword` remains part of `formSchema` because it is rendered in the -form, but it is simply not included in the typed mutation argument. - -## (d) Server Field Error via setError - -```ts -const onSubmit = handleSubmit(async (values) => { - try { - await mutation.mutateAsync({ - username: values.username, - password: values.password, - }); - } catch (error) { - if (isFieldTakenError(error)) { // detect from typed transport error - setError("username", { message: "Username is already taken" }); - return; // handled — do not rethrow - } - showError(error, { fallbackMessage: "Unable to save" }); - } -}); -``` - -## (e) Controller Fallback (no native input contract) - -```tsx -// Use for primitives or product fields that expose a controlled value contract. -<Controller - control={control} - name="enabled" - render={({ field }) => ( - <UiSwitch checked={field.value} onCheckedChange={field.onChange} /> - )} -/> -``` - -## (f) Shared Create / Edit Form - -Mount edit mode only after its entity is available. Remount when its identity -changes: - -```tsx -<EntityForm key={entity.id} entity={entity} /> -``` - -Use a distinct create-mode instance: - -```tsx -<EntityForm key="create" /> -``` - -Inside the shared form: - -```tsx -function EntityForm({ entity }: { entity?: Entity }) { - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: entity?.name ?? "", - description: entity?.description ?? "", - }, - }); - - const createMutation = useCreateEntityMutation(); - const updateMutation = useUpdateEntityMutation(); - - const onSubmit = form.handleSubmit(async (values) => { - if (entity) { - await updateMutation.mutateAsync({ - id: entity.id, - name: values.name, - description: values.description, - }); - return; - } - - await createMutation.mutateAsync({ - name: values.name, - description: values.description, - }); - }); - - // Render the same form fields for both modes. -} -``` - -Do not synchronize later entity updates into the form with `useEffect`. diff --git a/.claude/skills/implement-work-item/SKILL.md b/.claude/skills/implement-work-item/SKILL.md deleted file mode 100644 index c6b04f1..0000000 --- a/.claude/skills/implement-work-item/SKILL.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: implement-work-item -description: Claim and implement exactly one ready issue from a central project change bundle in its exact bound leaf checkout or worktree. Use when the maintainer explicitly asks to implement a named or next frontier issue, or when resuming its existing claim. Do not use for an unresolved Wayfinder issue, unapproved shaping, lightweight unrelated edits, or work in an inferred checkout. ---- - -# Implement Work Item - -Build one bounded unit from a fresh, explicit context. The issue tracks local -progress; `change.md` remains the parent contract. - -Read [the execution contract](references/execution-contract.md) before the -first claim in a repository. - -## Load before claiming - -1. Run `wfctl work issue show <change-id> <issue-id>` from the exact intended - leaf. If no issue was named, run `wfctl work issue list <change-id>` and - choose only a frontier issue. -2. Read every listed required file completely. This includes the parent spec, - selected issue, transitive blockers, and referenced artifacts. -3. After each complete read, run `wfctl work review file <change-id> <path>`. - Do not record a receipt after reading only headings, summaries, or excerpts. -4. Run `wfctl work status <change-id>` and verify the current Git root equals - the reported code root exactly. -5. Claim before analysis or edits: - -```sh -wfctl work issue claim <change-id> <issue-id> --actor "agent:<identity>" -``` - -The claim records repository, branch, revision, and worktree identity. Stop on -any mismatch instead of choosing a sibling checkout. - -## Implement one tracer bullet - -Invoke `analyze-with-graphify` in every repository this issue legitimately -touches, then inspect the actual source. Recheck relevant curated knowledge -with `align-project-knowledge`. Search may supplement the graph; it does not -replace it. - -Implement the smallest complete behavior that satisfies this issue. Prefer a -high public seam and work one behavior cycle at a time: - -1. add one externally meaningful failing check; -2. confirm it fails for the intended reason; -3. make the minimum production change that passes it; -4. run the focused check; -5. repeat, then run broader relevant checks. - -Tests must derive expected behavior from the approved contract or an -independent authority, never from the implementation they are meant to test. -Do not over-mock the behavior under review. - -After every material maintainer turn or meaningful investigation cycle, apply -the preservation test from `manage-project-work`: if losing newly learned -information could cause repeated material investigation, a different choice, -misunderstanding, or unsafe action in a fresh session, append a complete entry -to the issue's `Discovery ledger`. Record observation, evidence, implication, -scope, and disposition without forcing it into a predefined finding category. -Update evidence and current understanding next, then refresh the issue's single -structured checkpoint last: - -```sh -wfctl work checkpoint <change-id> --issue <issue-id> \ - --actor "agent:<identity>" \ - --state "<current state>" \ - --last "<last completed action>" \ - --next "<exact next action>" -``` - -Use `--status blocked --blocker "<reason>"` when progress genuinely cannot -continue. Record deviations in the parent `change.md` when they affect approved -scope, acceptance, or decisions; refresh the parent checkpoint and reopen -framing review before continuing materially different work. - -The checkpoint may identify the latest discovery and its effect on the next -action, but the full information stays in the semantic record or a linked -artifact. Never hide a discovery only in checkpoint prose, command output, or -conversation memory. - -## Resolve honestly - -Inspect the real diff and production path. Record commands, direct source -evidence, limitations, placeholders, and unresolved risk. With normal -maintainer authorization, preserve code in the exact bound Git commit; `wfctl` -never commits automatically. - -Resolve only this issue: - -```sh -wfctl work issue complete <change-id> <issue-id> \ - --summary "<delivered outcome>" \ - --evidence "<direct inspection or command result>" -``` - -If interrupted, refresh the claimed issue checkpoint before stopping. A fresh -session begins with `wfctl work context --stage resume`, reads every required -file and discovery entry completely, and resumes the existing exact claim; it -does not infer another issue, actor, checkout, or code root. If -deliberately giving the issue back, run `wfctl work issue release`; it resets -the issue checkpoint to ready. Completion makes the issue checkpoint terminal; -then refresh the parent checkpoint with the next frontier action. Do not mark a -partial outcome completed. Final change-wide review, drafting the pages this work -changes, and closure belong to `verify-project-work` after every required issue -is terminal; the maintainer's approval of those pages comes after the bundle is -closed, and holds nothing up. diff --git a/.claude/skills/implement-work-item/agents/openai.yaml b/.claude/skills/implement-work-item/agents/openai.yaml deleted file mode 100644 index cd9c2c7..0000000 --- a/.claude/skills/implement-work-item/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Implement Work Item" - short_description: "Implement one issue with durable discoveries" - default_prompt: "Use $implement-work-item to claim or resume one frontier issue in the exact bound checkout, preserve consequential discoveries in the issue, verify it, and refresh its checkpoint last." -policy: - allow_implicit_invocation: false diff --git a/.claude/skills/implement-work-item/references/execution-contract.md b/.claude/skills/implement-work-item/references/execution-contract.md deleted file mode 100644 index 5eea8b5..0000000 --- a/.claude/skills/implement-work-item/references/execution-contract.md +++ /dev/null @@ -1,29 +0,0 @@ -# Work-item execution contract - -## Workspace invariants - -- The central bundle is the record workspace. -- Each `Code root` from `wfctl work status` is an implementation workspace. -- A linked worktree is a distinct root even when it shares Git objects. -- Never infer a checkout from branch name, repository name, sibling paths, or - the location of `change.md`. -- Re-run status after directory changes, compaction, branch changes, and before - verification. - -## Progress invariants - -- Work exactly one claimed issue. -- Keep the issue current after requirements, evidence, scope, or the next - action changes. -- Keep parent acceptance and decisions in `change.md`; link rather than copy. -- A fresh session resumes from `wfctl work context`, the full files it lists, - and the exact claim—not from chat memory. - -## Verification invariants - -- Inspect the production path, not only tests or generated graph output. -- Prefer behavior checks at stable public seams. -- Run focused checks during development and the broader relevant suite before - resolution. -- Separate verified facts, inference, and unverified limitations. -- Passing checks do not excuse an acceptance criterion that was never traced. diff --git a/.claude/skills/localization/SKILL.md b/.claude/skills/localization/SKILL.md deleted file mode 100644 index 1ed1b71..0000000 --- a/.claude/skills/localization/SKILL.md +++ /dev/null @@ -1,500 +0,0 @@ ---- -name: localization -description: Use whenever work adds, changes, reviews, or debugs user-facing copy in an application that uses localization, including visible text, labels, placeholders, accessibility text, validation messages, notifications, translation calls, source-text or semantic keys, locale resources, interpolation, plurals, context variants, typed translation APIs, enum labels, locale switching, or translation linting. Trigger even when the request mentions only changing UI wording without explicitly naming i18n. ---- - -# Localization - -When an application uses localization, every user-facing message goes through -its translation layer: - -- visible text and actions; -- labels, descriptions, placeholders, and hints; -- empty, loading, success, and error messages; -- validation feedback and notifications; -- `aria-label`, image `alt`, and other accessibility text. - -An application without localization should not receive a speculative partial -i18n layer. Adopt this skill when localization already exists, is being -introduced, or a translatable-string audit is explicitly requested. - -Inspect the installed localization library, initialization, source locale, -resource files, translator APIs, plural/context conventions, and validation -commands before changing copy. - -## Use Source Text As The Default Key - -For ordinary UI copy, use the canonical source-language text itself as the -translation key: - -```ts -translate("Cancel"); -translate("Create a new project"); -translate("Changes are saved automatically"); -``` - -The source catalog repeats the source text as its value: - -```json -{ - "Cancel": "Cancel", - "Changes are saved automatically": "Changes are saved automatically", - "Create a new project": "Create a new project" -} -``` - -This makes the application searchable from its interface: copying visible text -and searching the repository should lead directly to the resource and ordinary -call sites. - -Do not replace ordinary copy with invented page-tree, component, kebab-case, or -UI-role identifiers such as: - -```text -projects.details.header.delete-project-btn -settings.notifications.description-body -``` - -File location and presentation role are not stable message identities. Moving a -component or changing a label into a button must not rename its translation. - -Use the exact source message, including meaningful capitalization and -punctuation. A wording change creates a new source key and requires existing -translations to be reviewed. Remove the obsolete key after migrating every call -site. - -## Reuse Meaning, Disambiguate Context - -Repeated source text is not inherently a collision. - -- Same text and same meaning → reuse one key. -- Same text but different meaning or required translation → disambiguate it. - -Prefer the localization engine's context feature: - -```ts -translate("Open", { context: "action" }); -translate("Open", { context: "state" }); -``` - -Store the base message and the engine's contextual variants in every locale. -For example, i18next uses its configured `contextSeparator` (`_` by default): - -```jsonc -// source locale -{ - "Open": "Open", - "Open_action": "Open", - "Open_state": "Open" -} -``` - -```jsonc -// another locale -{ - "Open": "Відкрити", - "Open_action": "Відкрити", - "Open_state": "Відкрито" -} -``` - -The call site passes the unsuffixed source key and semantic context. The -localization engine resolves the appropriate contextual resource key: - -```text -translate("Open", { context: "action" }) -> Open_action -translate("Open", { context: "state" }) -> Open_state -``` - -Do not append the context suffix manually at call sites. Follow the installed -engine's exact separator, fallback, typing, and plural/context composition -rules. Keep a base entry when the engine uses it as the non-contextual fallback. - -Context describes linguistic meaning, not file placement. Add a translator -comment when the library or catalog supports one. - -If the established runtime has no context mechanism, use the project's explicit -disambiguation convention while keeping the source text searchable, for -example: - -```ts -translate("Open|action"); -translate("Open|state"); -``` - -```jsonc -// source locale -{ - "Open|action": "Open", - "Open|state": "Open" -} -``` - -```jsonc -// another locale -{ - "Open|action": "Відкрити", - "Open|state": "Відкрито" -} -``` - -Do not invent a delimiter or alternate context system when the project already -has one. - -Short words such as `"Save"`, `"Cancel"`, `"Continue"`, and `"Name"` follow the -same rule. Use the source text directly when its meaning is shared; add context -only for a real ambiguity. - -## Keep Whole Messages Together - -Message length alone is not a reason to invent a semantic key. A sentence or -paragraph that forms one translatable unit may remain its own source-text key: - -```ts -translate( - "Deleting this project will permanently remove its settings and associated data.", -); -``` - -Never split natural language into separately translated fragments merely to -shorten a key. Translators must be able to reorder the whole message. - -Use a stable semantic id with an explicit source/default value only when the -content is genuinely managed as structured content rather than ordinary UI -copy, for example: - -- multi-paragraph help or onboarding content; -- localized Markdown or rich text; -- legal documents; -- large independently maintained content blocks. - -```ts -translate("project-deletion-explanation", { - defaultValue: projectDeletionExplanation, -}); -``` - -## Interpolation, Plurals, And Formatting - -Keep placeholders inside the complete source message and pass their values -through the translator: - -```ts -translate("Delete {{name}}?", { name }); -translate("Created by {{author}}", { author }); -``` - -Do not concatenate or template together fragments of natural language. A -rendered message containing a dynamic value may not exactly match its source -key, but its static wording remains searchable. - -### Plurals - -Use the localization engine's plural/select support for counts and grammatical -variants. Do not choose English singular/plural forms with component logic. -Follow the installed engine's resource format because plural categories differ -between locales. - -For example, i18next JSON v4 resolves plural variants from a base key and the -required `count` option: - -```ts -translate("{{count}} project", { count }); -``` - -```jsonc -// source locale -{ - "{{count}} project": "{{count}} projects", - "{{count}} project_zero": "No projects", - "{{count}} project_one": "{{count}} project", - "{{count}} project_other": "{{count}} projects" -} -``` - -```jsonc -// a locale with additional plural categories -{ - "{{count}} project": "{{count}} проєктів", - "{{count}} project_zero": "Немає проєктів", - "{{count}} project_one": "{{count}} проєкт", - "{{count}} project_few": "{{count}} проєкти", - "{{count}} project_many": "{{count}} проєктів", - "{{count}} project_other": "{{count}} проєкту" -} -``` - -The base entry keeps the ordinary typed lookup and non-contextual fallback -explicit. The engine selects `_zero`, `_one`, `_few`, `_many`, or `_other` -according to the active locale. A locale only defines the categories required -by the installed engine and its plural rules; do not copy English categories -blindly. - -For i18next, the option must be named `count`. Other engines may use another -resource shape or ICU message syntax. Reuse the project's installed plural -mechanism and verify its current official documentation. - -Context and plural variants may be combined. Pass both `count` and `context`; -the engine composes their resource suffixes. Never construct `_one`, `_other`, -or combined suffixes in application code. - -### Locale-Aware Formatting - -Format dates, times, numbers, percentages, units, and currencies with the -project's locale-aware formatter. Do not interpolate locale-insensitive -`toString()` output into a translated sentence. - -When the localization engine supports `Intl`-backed formatting, keep the -formatter inside the complete message. For i18next versions that support its -built-in formatters: - -```jsonc -{ - "Total: {{amount, currency(USD)}}": "Total: {{amount, currency(USD)}}", - "Updated on {{date, datetime}}": "Updated on {{date, datetime}}", - "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete": "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete" -} -``` - -```ts -translate("Total: {{amount, currency(USD)}}", { - amount: 1250, -}); - -translate("Updated on {{date, datetime}}", { - date: updatedAt, - formatParams: { - date: { - dateStyle: "medium", - timeStyle: "short", - }, - }, -}); - -translate( - "{{progress, number(style: percent; maximumFractionDigits: 1)}} complete", - { - progress: 0.725, - }, -); -``` - -The active locale controls separators, currency presentation, ordering, and -date/time wording. Other locales keep the same placeholders but may move them -within the message. - -If the localization engine does not own formatting, format through the -project's locale-aware formatter and interpolate the result: - -```ts -translate("Total: {{amount}}", { - amount: formatCurrency(amount, { currency: "USD", locale: activeLocale }), -}); -``` - -## Use Stable Domain Keys For Enums And Machine Values - -Enums and other closed machine-defined sets already have stable identities. -They do not need source text as their lookup key. - -When a value is presented in multiple ownership areas, keep one exhaustive, -typed translator: - -```ts -const STATUS_KEYS = { - [Status.Active]: "enums.status.active", - [Status.Archived]: "enums.status.archived", -} satisfies Record<Status, TranslationKey>; - -export const translateStatus = (status: Status) => - translate(STATUS_KEYS[status]); -``` - -The resource values remain searchable: - -```json -{ - "enums.status.active": "Active", - "enums.status.archived": "Archived" -} -``` - -Apply this to stable statuses, roles, modes, categories, and similar constants. -The key must be mechanically derived from the domain value, not creatively -named after one component. - -Use a local source-text key when a one-off label only happens to resemble an -enum value. Do not route unrelated copy through a shared enum translator. - -## Derive Key Types From The Source Catalog - -The canonical source locale is the key authority. Derive key and language types -from real resources instead of maintaining manual unions: - -```ts -import source from "./locales/en.json"; - -export const resources = { - en: { translation: source }, - uk: { translation: uk }, -} as const; - -export type Language = keyof typeof resources; -export type TranslationKey = keyof typeof source; -``` - -For a flat source-text catalog, prefer the simple `keyof` type. Do not build a -recursive path utility that: - -- permits intermediate objects as translation results; -- generates both dot and bracket forms; -- duplicates the localization library's own key inference; -- slows TypeScript as the catalog grows. - -When the library supports resource-based type augmentation, connect it directly -to the source catalog. For i18next: - -```ts -declare module "i18next" { - interface CustomTypeOptions { - defaultNS: "translation"; - keySeparator: false; - nsSeparator: false; - returnObjects: false; - resources: { - translation: typeof source; - }; - } -} -``` - -Keep runtime configuration and type augmentation aligned. A flat natural-key -i18next catalog normally requires: - -```ts -i18n.init({ - resources, - keySeparator: false, - nsSeparator: false, -}); -``` - -Otherwise periods or colons inside sentences may be interpreted as key or -namespace separators. Follow the exact installed library version and official -documentation when configuring this foundation. - -If the project uses multiple catalogs or namespaces, split them for real -ownership, loading, or deployment reasons. Do not recreate page-tree -namespacing merely to organize keys visually. - -JSON resources provide useful key inference but may not preserve enough literal -information for fully typed interpolation variables. If stronger typing is -needed, prefer source resources defined with `as const` in TypeScript or -generated declarations. Never hand-maintain a second resource interface. - -## Keep Translator APIs Honest - -Prefer the localization library's typed translator directly. A project wrapper -may adapt ergonomics, but it must preserve: - -- the inferred key type; -- option and interpolation types; -- context and plural overloads; -- the real return type. - -Do not weaken the boundary with `any`, `as unknown as string`, or a general -`string` key. - -Do not custom-memoize translated results by only `key + options`. The active -locale, loaded resources, and runtime language changes also affect the result. -Use the localization engine's own resource behavior unless the project has a -proven locale-aware caching abstraction. - -Inside reactive UI, use the project's translation hook so the component -responds to locale changes. Outside React, use the established direct -translator. - -Do not eagerly translate module-level constants when the application can change -locale without reloading. Store keys and translate at the consumption boundary, -or construct locale-sensitive schemas/options through the project's established -flow. Module-level translation is acceptable only when the application -deliberately reloads on locale change or otherwise guarantees reevaluation. - -Do not pass a translator through component props when each component can obtain -the project translator from its normal context. - -## Validate Every Locale - -Typing call sites from the source locale proves that a used source key exists. -It does not automatically prove that every other locale has the same keys. - -Treat localization validation as layered. The foundation must detect missing, -extra, and orphaned keys; incompatible placeholders; incomplete plural/context -variants; invalid resource shapes; and stale keys after copy changes. - -No single successful command proves all of these properties. Combine the -repository's resource validator, exact parity or policy checks, source usage -analysis when it understands the project's translator API, and type-checking. - -When using or considering `@lingual/i18n-check`, read -[references/i18n-check.md](references/i18n-check.md) before trusting it. Its -resource checks are useful, but source parsing and i18next plurals have limits. - -Prefer automatic correction for deterministic ordering, but do not silently -fabricate translations. A source-language value copied into another locale must -remain visibly untranslated according to the project's workflow. - -Keep resource keys deterministically sorted when the project stores catalogs in -version control. - -## What Not To Translate - -- User-generated or backend-provided content. -- User names, record ids, filenames, and machine-readable codes. -- Proper-noun brands that intentionally remain identical across locales. -- Raw dynamic values that should be formatted rather than translated. - -Translate the surrounding static message as one unit. Do not assume -backend-provided labels are localized unless the API contract guarantees it. - -## Workflow - -1. Inspect the localization runtime, source locale, resources, typed translator, - context/plural conventions, and validation commands. -2. Find every affected user-facing string, including accessibility, - notification, and validation copy. -3. Search the exact source text before adding it. -4. Reuse an existing key when both source text and meaning match. -5. Add linguistic context when identical source text needs a different - translation. -6. Use a typed domain key only for an enum/machine value or a justified - structured-content exception. -7. Add the source entry and update other locales through the project's - translation workflow. -8. Replace the call site with the typed translator and keep interpolation or - plural logic inside the message. -9. Remove replaced or stale keys. -10. Run locale sorting/parity validation, formatting, linting, and type-checking. - -## Avoid - -- invented page/component/element keys for ordinary copy; -- namespaces derived from file-system position; -- duplicating identical messages per call site; -- sharing identical source text that needs different linguistic context; -- semantic ids for ordinary sentences merely because they are long; -- sentence fragments and translated-string concatenation; -- runtime-generated or untyped translation keys; -- manual key unions or duplicate resource interfaces; -- recursive path types for a flat source-text catalog; -- casts that hide object or missing-key results; -- custom translation memoization that ignores locale; -- assuming source-locale typing validates every locale. - -## Related Skills - -- Localized validation messages and form lifecycle → **forms**. -- User-facing component copy and locale-reactive rendering → **components**. -- Localized success, error, warning, notification, and recovery feedback → - **user-feedback**. -- Placement of localization modules and resource files → **file-structure**. diff --git a/.claude/skills/localization/references/i18n-check.md b/.claude/skills/localization/references/i18n-check.md deleted file mode 100644 index 8a2e157..0000000 --- a/.claude/skills/localization/references/i18n-check.md +++ /dev/null @@ -1,569 +0,0 @@ -# i18n-check Integration Reference - -Use this reference when a project already uses `@lingual/i18n-check`, when -introducing it, or when deciding whether its output is sufficient for a -localization change. - -## Contents - -- [Role And Boundaries](#role-and-boundaries) -- [Supported Checks](#supported-checks) -- [Recommended Commands](#recommended-commands) -- [Required Validation Layers](#required-validation-layers) -- [Day-To-Day Workflows](#day-to-day-workflows) -- [Source Usage Compatibility](#source-usage-compatibility) -- [Source-Text Key Edge Cases](#source-text-key-edge-cases) -- [Interpolation And Rich Messages](#interpolation-and-rich-messages) -- [Plurals And Context](#plurals-and-context) -- [Catalog And File Edge Cases](#catalog-and-file-edge-cases) -- [Ignore, Exclude, And Reporting](#ignore-exclude-and-reporting) -- [Version-Specific Caveats](#version-specific-caveats) -- [Integration Checklist](#integration-checklist) - -## Role And Boundaries - -Treat `i18n-check` as a resource compatibility checker, not as the complete -localization authority. - -It is effective at comparing a canonical source locale with target locale files -and returning a non-zero exit code for detected problems. This makes it useful -for local validation, pre-commit hooks, and CI. - -It does not translate copy, migrate renamed keys, judge translation quality, or -prove that every runtime localization path works. A successful run only proves -the checks that were actually enabled and supported by the installed version. - -Inspect all of the following before configuring it: - -1. The installed `@lingual/i18n-check` version. -2. The localization engine and message format. -3. The source locale and resource layout. -4. The project's translator functions, hooks, and rich-text components. -5. The runtime key, namespace, plural, and context separators. -6. Existing type-check, lint, extraction, and CI commands. - -Consult the installed package and current official documentation instead of -assuming that behavior described for another version still applies: - -- <https://lingual.dev/i18n-check/> -- <https://github.com/lingualdev/i18n-check> - -## Supported Checks - -The CLI exposes four checks: - -| Check | Intended guarantee | -| --- | --- | -| `missingKeys` | A source key exists in each matched target resource. | -| `invalidKeys` | Source and target messages preserve compatible message elements. | -| `unused` | A source-locale key was not found in parsed application source. | -| `undefined` | A parsed application key does not exist in the source locale. | - -By default, use `missingKeys` and `invalidKeys` as the dependable resource -validation layer. `unused` and `undefined` require `--unused`/`-u` and are only -reliable when the parser recognizes the project's actual call-site syntax. - -For i18next messages, `invalidKeys` can detect common structural changes such -as: - -- a missing, added, or renamed interpolation variable; -- escaped versus unescaped interpolation; -- changed i18next nesting expressions; -- missing or changed rich-text tags; -- changed interval-plural expressions supported by the parser. - -It deliberately ignores ordinary source and target text differences. It cannot -determine whether a translation is accurate, grammatical, current, or -appropriate for its context. - -The CLI can load one or multiple locale folders and supports common layouts -such as one file per locale, one folder per locale, and matching multiple files -inside each locale folder. Discovery and matching do not prove that an expected -locale or file exists; validate that separately. - -Format behavior is version-specific. Inspect support for ICU, i18next, -react-intl, and next-intl in the installed package instead of assuming that -every check works equally for every format. - -## Recommended Commands - -Make the resource contract explicit: - -```json -{ - "scripts": { - "validate:i18n:resources": "i18n-check -l src/localization/locales -s en -f i18next -o missingKeys invalidKeys" - } -} -``` - -Adjust paths, source locale, and format to the inspected project. Supported -resource inputs are JSON and YAML. - -Run the resource check: - -- after adding, removing, or changing localized copy; -- in the normal local validation workflow; -- in a pre-commit hook when it remains fast; -- in CI as the authoritative blocking gate. - -Pre-commit alone is insufficient because it can be bypassed. - -Only add source usage validation after a compatibility test: - -```json -{ - "scripts": { - "validate:i18n:usage": "i18n-check -l src/localization/locales -s en -f i18next -u src -o unused undefined" - } -} -``` - -Do not merge this command into a blocking workflow merely because it runs. First -confirm that it finds representative calls through every project translator, -hook, component wrapper, namespace, context, and dynamic-key registry. - -Prefer the standard reporter for actionable local and CI output. Use the -summary reporter only when exact affected keys are available through another -artifact. - -When the CLI cannot represent the project's resource ownership, use the -package's documented public check functions to compose project tooling. Verify -the installed exports and keep project-specific policy checks outside the -package. Do not depend on private `dist/` modules. - -## Required Validation Layers - -Use `i18n-check` as one part of this stack: - -| Layer | Responsibility | -| --- | --- | -| Resource validation | Missing source keys and structurally incompatible messages. | -| Project policy/parity validation | Expected locales/files, reverse base-key parity, plural/context completeness, and source-text policy. | -| Type-checking | Valid call-site keys, options, interpolation values, context, and count types. | -| Source usage analysis | Undefined and orphaned keys when the parser understands the project API. | -| Runtime or integration tests | Locale switching, fallback behavior, lazy resources, rich messages, and formatting. | -| Human review | Meaning, grammar, tone, cultural fit, and product terminology. | - -The project-specific policy validator should cover gaps that `i18n-check` does -not reliably cover: - -- an explicit manifest of supported locales and required resource files; -- bidirectional parity for ordinary keys and context identities after grouping - locale-specific plural variants; -- target-only and orphaned keys; -- duplicate resource keys; -- source-text key conventions and justified semantic-key exceptions; -- required plural categories for each locale; -- combined context and plural variants; -- placeholder compatibility in target-only plural categories; -- deterministic resource ordering when required. - -Do not weaken these guarantees to fit one tool. Extend the validation stack. - -## Day-To-Day Workflows - -### Add Copy - -1. Search for an existing source message with the same meaning. -2. Add the source entry. -3. Add or explicitly queue every target translation through the established - workflow. -4. Update the typed call site. -5. Run resource validation, project parity validation, and type-checking. - -`missingKeys` should identify a target locale that did not receive the new -source key. It cannot create the translation. - -### Change Source-Text Copy - -A wording change is a key migration: - -```ts -translate("Delete project?"); -translate("Delete this project?"); -``` - -Perform it atomically: - -1. Find every call site of the old exact message. -2. Add the new source key. -3. Carry each existing translation to the new key and review it against the new - meaning. -4. Update all call sites. -5. Remove the obsolete key from every locale. -6. Run resource validation, reverse parity, source usage validation when - compatible, and type-checking. - -`missingKeys` will report the new key when targets still contain only the old -key. It will not identify the change as a rename, migrate translations, or -report the old target-only key. - -Capitalization, punctuation, and meaningful whitespace are part of a -source-text key. Even a small wording edit creates a distinct key. - -### Change Interpolation - -When changing: - -```text -Hello {{name}} -Hello {{user}} -``` - -update the source message, every target message, the call-site option, and its -type. `invalidKeys` can catch a target that retained `{{name}}`; it does not -prove that the call site supplies `user`. - -### Remove A Feature - -Remove its call sites and owned source keys, then remove the same keys from -every target locale. - -The basic resource check will not report target-only leftovers. Use reverse -parity and, when compatible, `unused`. - -### Add A Locale Or Resource File - -Register the locale in the project's explicit locale manifest, create every -required resource file, then run validation. - -Do not depend on directory discovery to prove completeness. A checker cannot -report a locale or file that it was never told must exist. - -## Source Usage Compatibility - -The i18next source parser commonly recognizes direct forms such as: - -```ts -t("Save"); -i18n.t("Save"); -const { t } = useTranslation(); -t("Save"); -``` - -It also recognizes `Trans` and can be given additional component wrapper names -through `--parser-component-functions`. - -Do not assume that it recognizes project wrappers: - -```ts -translate("Save"); - -const tx = useTranslate(); -tx("Save"); -``` - -The CLI option for additional component functions extends `Trans` component -recognition; it does not necessarily configure arbitrary translation function -or hook names. Verify the installed version before relying on wrapper support. - -Do not rename a clear project API merely to satisfy a static parser. Prefer a -thin project-aware source checker or contribute configurable function support -upstream. - -Avoid importing private `dist/` parser modules into permanent project tooling. -Internal package paths and options are not a stable public contract. - -### Dynamic Keys - -Static analysis cannot generally resolve: - -```ts -t(`status.${status}`); -t(prefix + id); -translate(KEY_FROM_RUNTIME_DATA); -``` - -Prefer explicit typed registries for closed machine-defined values: - -```ts -const STATUS_KEYS = { - active: "enums.status.active", - archived: "enums.status.archived", -} as const; -``` - -Validate the registry exhaustively with TypeScript and include it in the -project's localization policy check. Do not scatter ignores for dynamic keys. - -### False Usage - -Inspect whether the selected source paths include: - -- tests and fixtures; -- stories and examples; -- generated code; -- comments containing `t(...)`; -- dead or unreachable modules. - -These can keep a production key classified as used. Some parser versions scan -translation-looking calls in comments. - -An unrelated application function named `t` can create the opposite problem: -the parser may treat its string argument as a translation key. - -Object-returning calls may also cause a whole subtree to be skipped: - -```ts -t("countries", { returnObjects: true }); -``` - -Treat an unused report as static evidence that still requires ownership-aware -review. - -### Namespaces - -Test multiple namespaces explicitly. Some versions treat a key as used without -fully proving that it was used through the correct namespace. - -Source-text keys containing `:` need particular care. A source parser may split: - -```ts -t("Error: invalid value"); -``` - -as namespace `Error` plus key ` invalid value`, even when runtime i18next uses -`nsSeparator: false` or natural-key detection. - -Resource-only checks remain useful, but `unused` and `undefined` are unreliable -until this case passes a project smoke test. - -## Source-Text Key Edge Cases - -`i18n-check` compares resource keys; it does not enforce the project's key -policy. - -Add project validation for these invariants: - -- ordinary source-text keys follow the canonical source wording; -- the canonical source value has not silently drifted from its key; -- context variants intentionally differ from their base key; -- semantic keys are limited to enums, machine values, or documented structured - content; -- a flat literal key cannot collide with an equivalent nested resource path. - -Do not apply a universal `key === value` rule blindly. Context variants and -justified semantic keys legitimately differ: - -```json -{ - "Open": "Open", - "Open_action": "Open", - "enums.status.active": "Active" -} -``` - -Literal periods and colons must agree with runtime `keySeparator` and -`nsSeparator` behavior. The resource checker does not prove runtime lookup. - -Treat plural and context suffixes as reserved according to the installed -localization engine. A normal semantic key that accidentally ends in `_one` or -`_other` may be normalized as a plural form by tooling. - -## Interpolation And Rich Messages - -Use `invalidKeys` to protect the structural contract between source and target -messages, then keep call-site correctness in the type system. - -Validate representative cases: - -- reordered placeholders remain valid; -- renamed or missing placeholders fail; -- escaped and unescaped interpolation cannot be mixed accidentally; -- formatting directives preserve their arguments; -- nested translation expressions reference real keys; -- rich-text tags preserve the elements required by the renderer. - -The checker may compare a set of message elements without proving their exact -semantic nesting, rendering behavior, or component binding. - -It also does not validate: - -- that a custom formatter is registered; -- that date, time, number, currency, or unit inputs have the right runtime type; -- that the active locale reaches the formatter; -- that a nested `$t(...)` reference resolves; -- that translated prose retained the intended meaning. - -Test those boundaries through types and focused runtime tests. - -## Plurals And Context - -i18next plural categories vary by locale and are based on `Intl.PluralRules`. -The `count` option is required for plural selection. Context and plural suffixes -can be combined. - -Consult the installed engine's current documentation: - -- <https://www.i18next.com/translation-function/plurals> -- <https://www.i18next.com/translation-function/context> - -Do not treat a successful i18next `missingKeys` check as proof of plural -completeness. Some `i18n-check` versions normalize plural suffixes to a base key. -Consequently, one existing target variant may satisfy the check even when other -required categories are absent. - -For example, this target may pass base-key presence despite being incomplete: - -```json -{ - "project_one": "{{count}} project" -} -``` - -Locale-specific categories can also escape structural comparison when the -source locale does not define the same category. A broken target `_few` or -`_many` message may therefore remain undetected. - -The project policy validator must: - -1. Determine cardinal categories for each supported locale. -2. Validate ordinal categories separately when used. -3. Treat `_zero` as an explicit optional override unless product requirements - make it mandatory. -4. Require the correct variants for every pluralized base key. -5. Repeat the check for every context variant. -6. Validate interpolation elements across all target-only categories. -7. Respect the installed engine's configured plural and context separators. - -Do not copy English plural categories into every locale merely to satisfy a -checker. - -## Catalog And File Edge Cases - -### Reverse Parity - -The normal comparison direction is source to target. A target-only key can pass -`missingKeys` and `invalidKeys`. - -Run a locale-aware reverse comparison or bidirectional parity check to find: - -- old target keys left after a source-text rename; -- keys removed from the source but not from targets; -- accidental target-only additions. - -Normalize recognized plural-category suffixes before deciding that a target key -is extra. Preserve context identity: a target-only context remains suspicious, -while a target locale may legitimately require `_few` or `_many` when the -source locale does not. - -### Missing Files And Locales - -Directory discovery only compares files it finds and can match. It does not -prove that a deleted locale or resource file was expected. - -Maintain an explicit locale/resource manifest and verify it before content -comparison. - -### Empty And Null Values - -Verify installed-version behavior for empty strings, `null`, `false`, and -numeric values. Some implementations use truthiness for missing checks, making -an intentionally empty string appear missing. - -Prefer string-valued leaf messages unless the established engine explicitly -supports another resource shape. - -### Duplicate Keys - -Standard `JSON.parse` accepts duplicate object keys and silently keeps the last -value. A checker that reads resources through `JSON.parse` cannot report the -overwritten definition. - -Use a duplicate-aware JSON parser, formatter, or lint rule before running -resource comparison. YAML parser behavior must also be verified rather than -assumed. - -### Nested And Flat Resources - -Many checkers flatten nested objects into dotted paths. Avoid mixing: - -```json -{ - "a.b": "flat", - "a": { - "b": "nested" - } -} -``` - -This can produce a collision after flattening even when the raw JSON keys are -different. - -### Unsupported Sources - -The CLI resource loader supports JSON and YAML. TypeScript resource objects, -remote catalogs, generated bundles, or custom formats require a generation -step or public API integration. - -Source parsing commonly covers `js`, `jsx`, `ts`, and `tsx`. Verify other -extensions such as `mjs`, `mts`, MDX, Vue, or Svelte before enabling blocking -usage checks. - -## Ignore, Exclude, And Reporting - -Use `--ignore` and `--exclude` only for an explicit, reviewed exception. - -Each exception must identify: - -- the exact key, locale, file, or ownership boundary; -- why the normal invariant does not apply; -- whether the exception is temporary; -- how removal will be detected. - -Avoid broad wildcard ignores. Verify their matching semantics in the installed -version; some versions use substring matching for wildcard prefixes and can -silence unrelated keys. - -Do not exclude an entire locale merely because it is incomplete unless product -policy explicitly allows that locale to ship incomplete. - -Use the standard reporter when developers must fix individual keys. A summary -is useful for metrics, but counts alone are insufficient remediation output. - -## Version-Specific Caveats - -The following behaviors were observed in `@lingual/i18n-check@0.9.5`. Re-test -them when the installed version differs: - -- the i18next source parser defaults to `t`, `useTranslation`, - `withTranslation`, and `Trans`; -- custom `translate` and custom translation hooks are not configurable through - the documented CLI; -- parsed keys containing `:` are split as namespace-prefixed keys; -- context usage normalization assumes `_`; -- plural suffixes are collapsed during missing and usage checks; -- namespace-aware usage matching is incomplete; -- `returnObjects: true` creates skippable key subtrees; -- source-file `--exclude` behavior does not necessarily match locale-file - exclusion behavior; -- wildcard ignores use broad substring matching; -- missing checks treat falsy target values as absent; -- interpolation prefix/suffix comparison contains an implementation defect. - -Do not preserve these limitations as timeless rules. They describe why the -installed tool must be tested against the real project instead of trusted by -name. - -## Integration Checklist - -Before declaring localization validation complete: - -- [ ] Identify the canonical source locale. -- [ ] Verify every expected locale and resource file through a manifest. -- [ ] Run `missingKeys` and `invalidKeys`. -- [ ] Run locale-aware reverse or bidirectional base-key parity. -- [ ] Detect duplicate resource keys before parsing destroys that evidence. -- [ ] Validate source-text, context, and semantic-key conventions. -- [ ] Validate cardinal and ordinal plural categories per locale. -- [ ] Validate combined context/plural variants. -- [ ] Confirm placeholder compatibility in every locale-specific variant. -- [ ] Type-check keys, translator options, interpolation values, and `count`. -- [ ] Prove the source parser recognizes every project translator API before - enabling `unused` or `undefined`. -- [ ] Test natural keys containing punctuation, especially `:` and `.`. -- [ ] Review every ignore or exclude exception. -- [ ] Run focused runtime tests for locale switching, fallback, lazy resources, - formatting, and rich messages. -- [ ] Run the blocking validation in CI. diff --git a/.claude/skills/logging/SKILL.md b/.claude/skills/logging/SKILL.md deleted file mode 100644 index eabedde..0000000 --- a/.claude/skills/logging/SKILL.md +++ /dev/null @@ -1,327 +0,0 @@ ---- -name: logging -description: Use whenever work adds, changes, reviews, debugs, or consumes application logging in web, React Native, Electron, Electrobun, or another client runtime. Trigger for logger calls, structured log or event records, child loggers, scopes and context, log levels, console usage, transports, browser or native persistence, Electron renderer-to-main logging, logging RPC, batching, buffering, flushing, file logs, logger call sites, origin stacks, source maps, symbolication, Sentry or OpenReplay integration, telemetry breadcrumbs, tracked application events, or deciding where logging infrastructure belongs, even if the request only mentions diagnostics, traces, persisted logs, analytics events, or replacing console calls. ---- - -# Logging - -Use one structured logging contract and facade across client runtimes. Keep -platform delivery behind transports so application code does not know whether a -record goes to a console, browser storage, a native file, or another process. - -Logging records diagnostic facts. It does not decide how an error is handled, -whether a user sees feedback, whether an operation retries, or whether an -exception becomes a tracked incident. - -Keep the failure stack and the logging origin distinct. An error stack answers -where the failure was created or thrown. An origin stack answers where a log or -asynchronous operation was initiated. Preserve both without rewriting either. - -Explicit application events may use the same facade and transport pipeline when -the record keeps its event identity. Do not infer analytics events by parsing -ordinary human-readable log messages. - -## Inspect Before Extending - -Before changing logging: - -1. Find the public logger facade and its factory. -2. Find where the root logger is configured for each runtime. -3. Find registered transports and their lifecycle. -4. For desktop applications, trace renderer-to-host delivery and identify which - runtime owns file access. -5. Check whether the repository already has log persistence, retention, - telemetry breadcrumbs, or an established privacy policy. -6. Check how development and production stacks are source-mapped or - symbolicated for every runtime and release channel. - -Extend the existing foundation when it preserves the boundaries below. Do not -create a second application logger for one feature or runtime. - -## Keep One Contract, Not One Runtime Instance - -Main, renderer, preload, workers, browser pages, and React Native JavaScript run -in separate environments. They cannot share one physical singleton. - -Provide: - -- one platform-neutral logger contract; -- one logger factory and facade behavior; -- one process-local root logger per runtime; -- platform transports selected at the runtime's composition root. - -Place that contract at the narrowest real common owner. A monorepo with several -runtimes may justify a package; a single React Native application may only need -`core/logging`. Do not manufacture a package boundary that has no consumers. - -Feature code imports only its configured logger facade. It must not import file -writers, RPC clients, telemetry SDKs, or storage adapters merely to emit a log. - -## Use A Direct Record Flow - -Keep the core pipeline small: - -```text -logger method - -> merge child scope - -> create one structured log or event record - -> fan out to configured transports -``` - -Do not insert generic normalization, conversion, sanitization, enrichment, or -middleware chains. A transport may perform the smallest representation change -required by its own boundary. Keep that change inside the transport. - -Read `references/facade-and-records.md` when defining or changing the logger -contract, record shape, child behavior, messages, levels, or error argument. - -## Separate Stable Scope From Event Context - -Use `child(...)` for values repeated by several records: - -```ts -const log = logger.child({ - module: "authentication", -}) - -const refreshLog = log.child({ - operation: "refresh-session", - requestId, -}) -``` - -Pass values belonging to one event to the log method: - -```ts -refreshLog.info("Session refreshed", { - userId, - expiresAt, -}) -``` - -When the facade supports explicit tracked events, use the dedicated method: - -```ts -const mtlsLog = logger.child({ module: "mtls" }) - -mtlsLog.event("mtls_provision", { - method: "jwt", -}) -``` - -The event name is a stable machine contract. It is not an ordinary `info` -message reinterpreted later by a telemetry transport. - -Keep scopes small and mostly flat. Prefer identifiers and operational metadata -over entire entities or live runtime objects. - -Snapshot the scope and event context shallowly when creating a logger or record -so later caller mutation cannot change a queued record. Do not deep-clone or -walk values; callers should provide small, boundary-compatible fields. - -Use stable human-readable messages and put variable values in context: - -```ts -// Avoid -log.info(`Project ${projectId} opened`) - -// Prefer -log.info("Project opened", { projectId }) -``` - -## Choose Levels By Meaning - -- `debug`: detailed diagnosis that may be disabled or dropped. -- `info`: a meaningful lifecycle event, state transition, or completed action. -- `warn`: an unexpected but recoverable state or an applied fallback. -- `error`: a failed operation or diagnostic error event. - -Do not log every function call or every successful request. Logging volume must -remain useful enough to inspect. - -Let each transport choose its own level threshold. Feature code must not know -whether a particular environment persists `debug`, `info`, or only higher -levels. - -An explicit application event is a separate record kind, even when its local -console or file representation uses the `info` level. - -## Preserve Diagnostic Origins - -Do not mistake the logger implementation frame for the real call site. -Capturing a stack inside a transport is too late: it points to the transport, -queue flush, RPC handler, or file writer. - -- Keep an original `Error` value unchanged in the local record. -- Capture an optional origin stack synchronously at the public logger call or - before scheduling work that will finish across an async boundary. -- Perform automatic capture in the public method itself. Capturing in a shared - private emitter or origin resolver leaves that helper as the leading frame. -- Store the origin separately, for example as `originStack`; never append it to - `error.stack`. -- Let infrastructure observers explicitly suppress automatic capture when they - have no earlier origin. A Query cache callback, global error listener, RPC - handler, and file writer must not label their observation stack as the - operation origin. -- Preserve a received renderer or worker origin when a host persists the - record. Do not replace it with the host ingestion stack. -- Do not remove frames by a fixed `split(...).slice(n)` rule. Stack formats and - wrapper depth differ between V8, JavaScriptCore, and Hermes. -- Do not capture a stack for every production `debug` or `info` record without - measuring the cost. Configure a deliberate policy, commonly all enabled - levels in development and `warn`/`error` or explicitly traced operations in - production. - -Read `references/trace-origins.md` whenever work touches stack traces, logger -call sites, async origins, source maps, symbolication, Query or Mutation -diagnostics, or cross-runtime log delivery. - -## Compose Platform Transports At Bootstrap - -The logger facade should exist before optional platform services initialize. -Keep a console transport available as the early and emergency fallback, then -register persistence, RPC, or telemetry transports at bootstrap. - -- Web may use console, browser persistence, and remote transports. -- React Native may use console, native persistence adapters, and telemetry. -- Electron or Electrobun main may use console and native file transports. -- Electron or Electrobun renderer may use console and a buffered RPC transport - that delivers records to the host-owned file transport. -- Tests may use an in-memory or no-op transport. - -Provider-specific telemetry transports belong to the provider integration -module. For example, an OpenReplay renderer package may expose a transport that -maps explicit event records to `trackEvent`, while application features remain -unaware of OpenReplay. If the repository deliberately defines every `error` -record as incident-worthy, the provider transport may also map those records to -`captureException`. That choice makes `logger.error` part of the incident -contract: do not keep a second reporting facade that captures the same failure. -If diagnostic errors and incidents differ, represent that distinction -explicitly instead of guessing from a message. - -Read `references/platform-transports.md` when adding transports, file -persistence, Electron renderer-to-main delivery, batching, flushing, or -transport lifecycle. - -Read `references/proven-platform-patterns.md` for compact web, React Native, -Electron, and Electrobun implementation shapes and the edge cases they expose. - -## Keep Transports Isolated - -A transport must never break application behavior or prevent another transport -from receiving a record. - -- Keep logger methods synchronous from the caller's perspective. -- Put queues and batching inside asynchronous transports, not the core logger. -- Bound every queue. -- Make registration idempotent and removable for tests, HMR, and teardown. -- Support best-effort `flush()` and `dispose()` where a transport needs them. -- Report a broken transport directly through a guarded console fallback, not - through the same logger. - -Do not hide an unbounded pre-initialization queue in the logger. Early records -may go only to the console until optional transports are ready. - -## Keep Platform Boundaries Honest - -In a desktop renderer, file access belongs to the privileged host. Deliver -structured records through the existing native-RPC foundation instead of -granting renderer code filesystem access. - -Application code still calls the common facade. The RPC client is an -implementation detail of the renderer transport, not the public logger used by -features. - -Prefer one batch ingestion method over duplicating `debug`, `info`, `warn`, and -`error` as RPC methods. The host must persist received renderer records without -re-logging them through its own root logger; re-logging changes source metadata, -timestamps, and can create loops or duplicates. - -Register logging RPC in the native handler registry, but do not expose it as an -agent capability merely because the repository uses the same registry for both. -Operational transport methods and user- or agent-invokable capabilities have -different semantics. - -## Do Not Build An Error Conversion System - -The logger may accept an `unknown` error value for local diagnosis. The logging -core must not inspect error classes, extract domain fields, traverse custom -causes, or maintain error-type registries. - -Local transports may use the original value. A transport that requires a wire -or persistent representation owns a small, explicitly lossy representation. For -an actual `Error`, it may preserve the standard text and stack directly. For -another value, use a small fallback such as `JSON.stringify`, with a final -string fallback if encoding throws. Keep a separately captured `originStack` -separate on the wire. Do not inspect domain fields or reconstruct an error -instance on the receiving side. - -If a known diagnostic value matters, the caller that knows its meaning should -pass it explicitly in context. Adding a new application error type must not -require editing logging infrastructure. - -## Do Not Promise Automatic Secret Cleanup - -Do not pass passwords, tokens, cookies, private keys, raw authorization -payloads, or unnecessary personal data to the logger. - -Do not invent a recursive sanitizer and rely on it to make unsafe logging safe. -If a repository or telemetry SDK already provides a proven boundary safeguard, -preserve it as defense in depth without turning it into a general application -error-conversion pipeline. - -## Keep Observability Concerns Distinct - -- Sentry-style breadcrumbs may be implemented as a logging transport. -- Decide whether `logger.error` means a diagnostic error or an incident-worthy - error. Do not automatically capture every error unless the repository makes - that contract explicit. -- When an incident provider is a logger transport, emit one eligible record and - let normal fan-out reach local persistence and the provider. Do not call the - provider separately from the same `reportError` flow. -- Stable typed application events may travel through the logging pipeline when - the facade and record distinguish them from ordinary logs. -- Metrics, timings, and analytics events must not be derived from human log - messages or an `info` level alone. -- Provider session control such as initialization, user identity, consent, and - reset is not a log transport and remains in the provider integration. -- User feedback is UI behavior, not a logging transport. - -## Verify The Result - -Before finishing logging work, verify that: - -- feature code imports only the configured logger facade; -- child scope and event context remain distinct; -- explicit application events remain distinguishable from ordinary logs; -- queued records cannot change when the caller later mutates its scope or - context object; -- messages are stable and dynamic values are structured; -- transports are registered once and fail independently; -- asynchronous transports have bounded queues and a flush policy; -- browser or Storybook execution does not instantiate a native transport when - its bridge is absent; -- renderer persistence crosses the established native boundary; -- the host preserves the renderer record rather than re-logging it; -- an error stack and a separately captured origin stack remain distinct; -- origin capture happens before async, queue, worker, or RPC boundaries; -- persisted and remote production stacks are symbolicated against artifacts - from the exact application release or update; -- existing persisted-log schemas remain readable or have an explicit migration; -- infrastructure observers can suppress misleading automatic origin capture; -- transport failures cannot recurse through the logger; -- no new normalization, sanitizer, or error-type registry was introduced; -- sensitive values are absent from records and transport payloads. - -## Related Skills - -- Native RPC contracts, handlers, renderer clients, and host registration -> - **native-integration**. -- Placement of the logging package, platform entrypoints, and local helpers -> - **file-structure**. -- Query and mutation ownership remains in **api-integration**; logging a request - does not move cache or error-handling responsibilities into this skill. -- Catch boundaries, reporting policy, retries, cancellation, and typed error - outcomes → **error-handling**. -- Error, success, warning, fallback, and recovery presentation → - **user-feedback**. diff --git a/.claude/skills/logging/agents/openai.yaml b/.claude/skills/logging/agents/openai.yaml deleted file mode 100644 index 53896fb..0000000 --- a/.claude/skills/logging/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Logging" - short_description: "Structured cross-platform application logging" - default_prompt: "Use $logging to design or update structured logging across web, React Native, Electron renderer, and Electron main runtimes." diff --git a/.claude/skills/logging/references/facade-and-records.md b/.claude/skills/logging/references/facade-and-records.md deleted file mode 100644 index baf2479..0000000 --- a/.claude/skills/logging/references/facade-and-records.md +++ /dev/null @@ -1,368 +0,0 @@ -# Logger Facade And Records - -Use this reference when defining or changing the public logger, record shape, -child behavior, message conventions, levels, or error argument. - -## Contents - -- [Minimal Contract](#minimal-contract) -- [Root And Child Loggers](#root-and-child-loggers) -- [Scope And Context](#scope-and-context) -- [Stable Messages](#stable-messages) -- [Explicit Application Events](#explicit-application-events) -- [Level Semantics](#level-semantics) -- [Error Values](#error-values) -- [Diagnostic Origins](#diagnostic-origins) -- [Transport Dispatch](#transport-dispatch) - -## Minimal Contract - -Adapt names to the repository, but preserve the separation between stable scope -and event-specific context: - -```ts -type LogLevel = "debug" | "info" | "warn" | "error" -type LogKind = "log" | "event" - -type LogScope = Record<string, unknown> -type LogContext = Record<string, unknown> - -interface LogRecord { - timestamp: number - kind: LogKind - level: LogLevel - message: string - scope: LogScope - context?: LogContext - error?: unknown - originStack?: string -} - -interface Logger { - debug(message: string, context?: LogContext): void - info(message: string, context?: LogContext): void - warn(message: string, context?: LogContext): void - error(message: string, error?: unknown, context?: LogContext): void - event(name: string, context?: LogContext): void - child(scope: LogScope): Logger -} -``` - -This is illustrative rather than a mandatory literal type. Reuse an established -contract when it represents the same behavior. - -## Root And Child Loggers - -Create one root logger for each runtime: - -```ts -const logging = createLogging({ - scope: { - runtime: "electron-renderer", - applicationVersion, - sessionId, - }, - transports: [consoleTransport], -}) - -export const logger = logging.logger -``` - -Runtime metadata belongs to the root configuration. Feature code should not -repeat it. - -A child creates another immutable facade with merged scope: - -```ts -const moduleLog = logger.child({ - module: "projects", -}) - -const operationLog = moduleLog.child({ - operation: "open-project", - requestId, -}) -``` - -Creating a child must not create another transport registry, queue, timer, or -file handle. All descendants dispatch through the same runtime logging -instance. - -Snapshot the supplied root and child scope objects shallowly. Otherwise a -caller can mutate an object after creating the logger and silently rewrite the -scope observed by a delayed transport: - -```ts -const scope = { module: "projects" } -const log = logger.child(scope) - -scope.module = "unrelated" // Must not alter `log`. -``` - -Use a child when several records share the value: - -```ts -const syncLog = logger.child({ - module: "sync", - operationId, -}) - -syncLog.info("Synchronization started") -syncLog.debug("Synchronization batch received", { itemCount }) -syncLog.info("Synchronization completed", { durationMs }) -``` - -Do not create a child merely to emit one line: - -```ts -logger.info("Project opened", { projectId }) -``` - -## Scope And Context - -Typical scope fields include: - -- runtime; -- module or capability; -- component or process; -- operation; -- request, correlation, session, or task identifier. - -Keep scope values small and suitable for transport. Do not attach service -clients, stores, React values, request objects, whole entities, or arbitrary -class instances. - -Context belongs to one record: - -```ts -log.info("Workspace loaded", { - workspaceId, - projectCount, - durationMs, -}) -``` - -The logging core should merge scope and preserve context without walking, -normalizing, or enriching their values. Take a shallow snapshot of event -context when creating the record because an asynchronous transport may flush -later. This only protects the top-level record fields; do not deep-clone nested -objects. - -## Stable Messages - -Keep messages readable and stable: - -```ts -// Avoid: every id produces a different message. -log.info(`Workspace ${workspaceId} loaded in ${durationMs}ms`) - -// Prefer: the message groups naturally; values remain queryable. -log.info("Workspace loaded", { - workspaceId, - durationMs, -}) -``` - -Do not require a second machine event name for every log. Metrics and analytics -events must not be inferred by parsing log messages. - -## Explicit Application Events - -When an application sends stable domain events to OpenReplay or another -telemetry provider, the configured logger may expose a distinct `event` -operation: - -```ts -const log = logger.child({ module: "mtls" }) - -log.event("mtls_provision", { - method: "jwt", -}) -``` - -The resulting record must retain `kind: "event"` across asynchronous queues and -renderer-to-host boundaries. Console and file transports may store it alongside -ordinary logs. A telemetry transport can forward it without guessing from the -message or level. - -Do not forward every `info` record as analytics. That silently changes -diagnostic text into a remote data contract, increases telemetry volume, and -makes harmless message edits alter dashboards. - -## Level Semantics - -### Debug - -Use for high-volume details needed while investigating behavior: - -```ts -log.debug("Request batch scheduled", { - requestCount, - delayMs, -}) -``` - -Debug records may be disabled, sampled, or dropped by a transport. - -### Info - -Use for meaningful lifecycle and business-operation milestones: - -```ts -log.info("Workspace opened", { workspaceId }) -``` - -Do not log every render, selector, helper call, or successful network request. - -### Warn - -Use when the application continued despite unexpected or degraded behavior: - -```ts -log.warn("Cached configuration unavailable; defaults applied", { - configurationId, -}) -``` - -A warning should communicate what degraded or what fallback was selected. - -### Error - -Use for a failed operation or diagnostic error event: - -```ts -log.error("Workspace synchronization failed", error, { - workspaceId, - operationId, -}) -``` - -This call records a failure. It does not mean the failure was handled, shown to -the user, retried, or reported as an incident. - -## Error Values - -Keep the public error parameter `unknown`. Do not require errors to inherit from -one application base class. - -The core logger passes the value to local transports unchanged. If a transport -must encode it, keep the fallback local and deliberately small: - -```ts -function stringifyError(error: unknown): string | undefined { - if (error === undefined) { - return undefined - } - - try { - const serialized = JSON.stringify(error) - return serialized && serialized !== "{}" ? serialized : String(error) - } catch { - try { - return String(error) - } catch { - return "[unserializable error]" - } - } -} -``` - -`JSON.stringify(new Error("failed"))` commonly produces `{}` because standard -error fields are not enumerable. Falling back to `String(error)` in that case -preserves the basic message without introducing a universal serializer, -middleware chain, or error registry. - -When a field is important and the caller knows its semantics, provide it -explicitly: - -```ts -log.error("Request failed", error, { - requestId, - status, -}) -``` - -Do not teach the logger to discover `status`, `details`, response bodies, or -domain-specific properties from arbitrary errors. - -## Diagnostic Origins - -An optional `originStack` belongs to the record itself, not ordinary event -context. It describes where the facade was called or where later asynchronous -work was initiated. - -Capture it in the public method before dispatch: - -```ts -function error( - message: string, - error?: unknown, - context?: LogContext, - options?: LogCallOptions, -): void { - const captureOrigin = - options?.captureOrigin ?? captureOriginForConfiguredPolicy("error") - const originStack = - options?.originStack ?? - (captureOrigin ? captureTraceOrigin().stack : undefined) - - dispatch({ - timestamp: Date.now(), - kind: "log", - level: "error", - message, - scope, - ...(context === undefined ? {} : { context: { ...context } }), - ...(error === undefined ? {} : { error }), - ...(originStack === undefined ? {} : { originStack }), - }) -} -``` - -Do not capture inside `dispatch()` or `transport.write()`: the first meaningful -frame will already be logging infrastructure. Do not concatenate the result -with `error.stack`. - -Do not hide the capture in a generic private emitter or origin resolver either: -that helper becomes the leading frame. An observer with no real operation -origin passes `{ captureOrigin: false }`; ordinary feature calls rely on the -configured runtime policy. - -Keep capture policy at bootstrap so development can retain more detail than a -high-volume production runtime. Read `trace-origins.md` for portable capture, -async origins, native boundaries, and source-map requirements. - -## Transport Dispatch - -The core behavior can remain conceptually small: - -```ts -function emit( - level: LogLevel, - message: string, - context?: LogContext, - error?: unknown, -): void { - const record: LogRecord = { - timestamp: Date.now(), - kind: "log", - level, - message, - scope, - ...(context === undefined ? {} : { context: { ...context } }), - ...(error === undefined ? {} : { error }), - } - - for (const transport of transports) { - try { - transport.write(record) - } catch { - reportTransportFailureOnce() - } - } -} -``` - -Do not copy this literally when the repository already owns the facade. The -important properties are one record, one fan-out step, independent transports, -and no generic processor chain between them. diff --git a/.claude/skills/logging/references/platform-transports.md b/.claude/skills/logging/references/platform-transports.md deleted file mode 100644 index f23b5ea..0000000 --- a/.claude/skills/logging/references/platform-transports.md +++ /dev/null @@ -1,339 +0,0 @@ -# Platform Transports - -Use this reference when adding or changing transport registration, browser or -native persistence, Electron or Electrobun renderer-to-host delivery, batching, -flushing, or teardown. - -## Contents - -- [Transport Contract](#transport-contract) -- [Early Logging](#early-logging) -- [Transport Failure](#transport-failure) -- [Web](#web) -- [React Native](#react-native) -- [Electron And Electrobun](#electron-and-electrobun) -- [File Persistence](#file-persistence) -- [Stack And Origin Preservation](#stack-and-origin-preservation) -- [Telemetry](#telemetry) -- [Tests](#tests) - -## Transport Contract - -Keep the caller-facing logger synchronous. An asynchronous transport owns its -queue and lifecycle: - -```ts -interface LogTransport { - write(record: LogRecord): void - flush?(): Promise<void> - dispose?(): Promise<void> -} -``` - -Register transports at the runtime composition root: - -```ts -const logging = createLogging({ - scope: { - runtime: "web", - }, - transports: [createConsoleTransport()], -}) - -const removePersistence = logging.addTransport( - createBrowserPersistenceTransport(), -) -``` - -`addTransport()` should return a removal function or another clear lifecycle -handle. Prevent duplicate registration during HMR, repeated bootstrap, tests, -or remounting. - -## Early Logging - -Make the configured facade importable before optional services initialize. -Keep a console transport available from the start. - -Do not add an unbounded hidden queue for records emitted before persistence or -RPC is ready. It is acceptable for early records to reach only the console. - -If an application must retain early records, make that bounded bootstrap buffer -an explicit project decision with a clear ownership and flush point. - -## Transport Failure - -One transport failure must not: - -- throw through the logger call; -- prevent another transport from receiving the record; -- recursively invoke the same logger; -- retry forever; -- grow an unbounded queue. - -A transport may emit one guarded, rate-limited `console.warn` describing its -own failure. Do not route that warning through the logger it is servicing. - -## Web - -A web runtime may compose: - -- a developer console transport; -- IndexedDB or another browser persistence transport; -- a remote logging or telemetry transport. - -Browser persistence should own its batching, retention, querying, export, and -cleanup behavior. Do not place IndexedDB knowledge in the logger facade. - -Treat an existing persisted schema as a compatibility contract. If the common -record changes from fields such as `t` and `tags` to `timestamp`, `scope`, and -`context`, adapt new records inside the persistence transport or perform an -explicit database migration. Do not silently make old IndexedDB records -unreadable. - -Treat page shutdown flushes as best effort. Use the repository's established -page lifecycle integration rather than claiming that every queued record is -durable. - -## React Native - -React Native usually presents one JavaScript runtime to application code, even -when persistence ultimately crosses a native module. - -Hide that bridge inside the React Native transport: - -```text -application logger - -> React Native transport - -> native logging or file capability -``` - -Feature code still imports the common configured facade. It must not call the -native persistence module directly for ordinary logging. - -Flush buffered records on the project's established application-background or -shutdown lifecycle when useful, without blocking user-visible transitions. - -Verify the filesystem API's relocation semantics before implementing rotation. -For example, APIs in which `move` mutates the source `File` object's URI can -accidentally make subsequent writes target the backup. Copying to the backup -and truncating the current file may be safer for that API. This is a -transport-specific decision, not logger-core behavior. - -## Electron And Electrobun - -Treat the privileged host and renderer as separate runtimes: - -```text -renderer feature - -> renderer logger facade - -> renderer console transport - -> bounded RPC transport - -> host logging handler - -> host-owned file transport - -host feature - -> host logger facade - -> host console transport - -> host-owned file transport -``` - -The host owns file paths, file creation, rotation, retention, reading, and -export. Do not grant the renderer filesystem access merely for logging. - -Use the repository's existing native-RPC foundation. The RPC client belongs -inside the renderer transport; feature modules do not import it. - -Only install the native transport when the bridge is actually available. -Storybook, browser previews, tests, or SSR may import the same configured -facade without a desktop host; those runtimes should retain console logging -without repeatedly failing native requests. - -### Batch Contract - -Prefer one ingestion method carrying a batch of structured records: - -```ts -const loggingMethods = { - writeBatch: "logging.writeBatch", -} as const - -interface WriteLogBatchParams { - records: RendererLogRecord[] -} -``` - -Reuse the repository's contract and validation system. Do not hand-maintain -parallel request shapes when a source of truth already exists. - -Register the method as ordinary native infrastructure. Do not expose log -ingestion to an agent or user-facing capability catalog unless a separate, -explicit product requirement calls for it. - -The wire record should preserve `originStack` and, for an actual error, a -minimal `errorText` and `errorStack`. Keep them as separate fields. Do not send -an `Error` instance, concatenate stacks, or introduce a generic domain error -serializer. - -The transport may apply the smallest wire-only representation change required -by the RPC implementation. Do not place that conversion in the core logger or -reuse it as a general application error model. - -### Renderer Queue - -The renderer RPC transport should: - -- preserve record order within a batch; -- cap its queue; -- flush on a short interval or batch-size threshold; -- avoid one RPC request per ordinary record; -- prefer dropping old `debug` records before more important records when full; -- expose a best-effort `flush()`; -- stop timers and reject new persistence work after `dispose()`; -- keep console logging available when RPC is unavailable. - -Exact batch sizes and intervals depend on the application. Keep them -configurable beside the transport rather than spreading constants through -feature code. - -### Host Ingestion - -The host handler receives records from the renderer and sends them directly to -the host-owned persistence sink. - -Do not call the host root logger again: - -```ts -// Avoid: creates a host record from a renderer record. -mainLogger.info(record.message, record.context) - -// Prefer: preserve the received renderer record. -fileTransport.write(record) -``` - -Re-logging can: - -- replace the renderer runtime with the host runtime; -- assign a second timestamp; -- duplicate console output; -- apply level filtering twice; -- create a loop when transports are composed incorrectly. - -The host's own application logs continue through its host root logger. - -### Infrastructure Failures - -Native RPC may itself need logging. Do not make successful delivery through the -same RPC channel the only way to diagnose its failure. - -Keep direct guarded console output available inside the logging and native-RPC -infrastructure. Avoid a cycle where an RPC failure logs through the failing RPC -transport indefinitely. - -## File Persistence - -The file transport owns: - -- record formatting, such as JSONL or readable text; -- file location; -- append and flush behavior; -- rotation and retention; -- file read/export capabilities; -- platform-specific filesystem errors. - -Reuse a public application-data path provider when one exists. Do not import a -different package's private runtime helper. When no public provider exists, -keep a minimal platform resolver inside the host transport or inject one from -the host composition root. - -Keep the stored representation stable enough for inspection, but do not turn -the file transport into a universal object or error serializer. - -Errors need a small boundary representation. Preserve the standard text and -stack of an actual `Error`; use the established small string fallback for other -values. Preserve an existing `originStack` independently. Scope and context -should already contain plain diagnostic values; if they are circular or -unsupported by the wire, dropping that record is preferable to adding a -recursive sanitizer to the shared logger. - -## Stack And Origin Preservation - -The transport must not invent a later origin: - -- a browser persistence transport keeps the facade-captured origin; -- a React Native native-file transport keeps the JavaScript error and origin - strings without depending on private LogBox or Metro APIs; -- a renderer RPC transport keeps renderer stacks; -- a desktop host writes received renderer stacks directly instead of replacing - them with handler or file-writer frames; -- a host-side failure remains a separate host error correlated by a safe request - identifier. - -Production persistence is useful only when the corresponding web, Node, -Hermes, or native source maps and symbols can be matched to the exact release -or OTA update. Read `trace-origins.md` for the capture helper, platform details, -symbolication, and verification matrix. - -## Telemetry - -A telemetry breadcrumb transport may receive ordinary structured records. -Apply the SDK's established filtering and lifecycle at that boundary. - -An application-event transport should receive only records explicitly marked -as events: - -```ts -const openReplayTransport: LogTransport = { - write(record) { - if (record.kind !== "event") return - - tracker.event(record.message, { - ...record.scope, - ...record.context, - level: record.level, - timestamp: new Date(record.timestamp).toISOString(), - }) - }, -} -``` - -Keep this transport in the provider integration package and register it at the -runtime composition root. Provider initialization, user identity, consent, and -reset remain explicit provider operations rather than logger methods. - -Do not automatically translate every `error` record into an exception incident. -Exception capture has different semantics and belongs to error reporting. - -Do not derive counters, timings, or analytics events from ordinary log messages -or levels. Use explicit typed instruments or event records. - -## Tests - -Use an in-memory transport to assert records without mocking the console: - -```ts -function createMemoryTransport() { - const records: LogRecord[] = [] - - return { - records, - write(record: LogRecord) { - records.push(record) - }, - } -} -``` - -Verify: - -- child scopes merge without mutating their parents; -- one method call creates one record; -- one broken transport does not block another; -- registration and removal are deterministic; -- queue limits and drop policy are enforced; -- flush sends the remaining batch; -- host ingestion preserves renderer metadata and timestamps. -- original error stacks remain unchanged after repeated transport delivery; -- origin stacks survive queues and renderer-to-host delivery as separate data; -- telemetry transports ignore ordinary records unless their policy explicitly - includes them; -- event identity survives any RPC or persistence boundary. diff --git a/.claude/skills/logging/references/proven-platform-patterns.md b/.claude/skills/logging/references/proven-platform-patterns.md deleted file mode 100644 index b638c53..0000000 --- a/.claude/skills/logging/references/proven-platform-patterns.md +++ /dev/null @@ -1,162 +0,0 @@ -# Proven Platform Patterns - -Use these compact shapes when adapting the logging foundation to a concrete -client runtime. They are examples of the same contract, not four competing -logger designs. - -## Web - -Place a reusable facade in a package when several workspace modules or -applications can consume it. Keep browser persistence application-owned: - -```text -packages/logger - -> contract, factory, console transport - -apps/web/core/logger - -> configured web root - -> IndexedDB transport -``` - -The IndexedDB transport owns batching, retention, queries, export, and mapping -between the current common record and any legacy stored schema. - -New errors should receive the transport's minimal string fallback before JSONL -export. Preserve an actual error stack and any separately captured origin stack. -Other non-cloneable context still fails best effort; do not add a recursive -common serializer. - -## React Native - -In a single-application repository, prefer an application module over an -artificial workspace package: - -```text -core/logging/index.ts - -> facade and process-local root - -core/logging/transports/native-file.ts - -> buffer, JSONL, rotation, native filesystem -``` - -Initialize once before application features start. Register a best-effort flush -when the established app lifecycle leaves the active state. Skip the native -file transport on web. - -Keep the original `Error` for console and incident providers. Persist its -standard stack separately from a facade- or operation-captured origin. Release -and OTA-update traces require their matching Hermes source maps. Include stable -release correlation in the root scope, such as the application version, -runtime version, and update identifier; an OTA stack without its exact update -identity cannot be matched reliably to an artifact. - -Keep filesystem behavior inside the transport and test the installed API -version. In particular, determine whether move/rename operations mutate the -source object before using them for rotation. - -## Electron - -Use one package with explicit runtime entrypoints: - -```text -logger -├── index.ts -├── native/client.ts -├── native/main/index.ts -└── preload.ts -``` - -- `index.ts` is Electron-free. -- `native/client.ts` configures renderer console plus bounded batch RPC. -- `native/main/index.ts` configures main console plus file persistence and - registers `writeBatch`. -- `preload.ts` owns a console-only process-local root. - -The renderer transport should only be installed when the preload bridge exists, -so browser previews and Storybook remain valid consumers. The main handler -writes received records straight to the file transport, including renderer -error and origin stacks. - -Choose one incident policy. - -```ts -// Diagnostic logger and explicit incident reporter are separate. -log.error("Workspace load failed", error, { workspaceId }) -reporting.captureException(error) -``` - -Use that shape when some error logs are diagnostic-only. Ensure one owner calls -both operations once; do not add a second global observer for the same failure. - -Alternatively, a repository may define every error record as incident-worthy: - -```text -reportError(error) - -> one logger.error record - -> console and file transports - -> incident-provider transport -``` - -In that policy, the provider transport owns `captureException` and receives the -original local `Error`. Application reporting code must not also call the -provider SDK. If only selected errors are incidents, add an explicit record kind -or call option rather than deriving intent from text. - -When the renderer also uses OpenReplay or another session provider, keep its -event adapter in that provider package: - -```text -feature logger.event(...) - -> renderer logger - -> console and file transports - -> OpenReplay event transport -``` - -The provider transport always forwards explicit event records. Under the -repository's incident policy it may also forward eligible error records, -preserving the original `Error`, scope, context, and separate `originStack`. -Create a fallback `Error` only inside the provider boundary when its SDK -requires one and the record contains a non-Error value. - -Initialization, identity, consent, and reset remain provider control operations; -they are not log transports. - -## Electrobun - -Use the same package split, replacing Electron entrypoints with the repository's -Electrobun native-module conventions: - -```text -renderer logger - -> console - -> bounded writeBatch client - -Bun handler registry - -> validate batch - -> host file transport -``` - -Register `writeBatch` in the native handler/module registry. If the repository -also derives agent capabilities from handler definitions, leave logging -unexposed unless it has explicit capability metadata. - -The host file transport may resolve its own application-data location or -receive a public resolver from the composition root. Do not reach into another -package's private filesystem implementation. - -## Shared Proof Obligations - -Across all four variants, verify: - -- shallow scope and context snapshots; -- original local error identity before a boundary; -- boundary-local error text and standard stack without domain conversion; -- origin stacks kept separate from error stacks; -- bounded async queues and deterministic flush; -- independent transport failures; -- stable persisted schemas or explicit adapters; -- no native requests when the native bridge is absent; -- no host re-logging of renderer records. -- matching source maps or symbols for production releases and updates; -- explicit event identity preserved across transports; -- no accidental promotion of ordinary `info` logs into remote events. diff --git a/.claude/skills/logging/references/trace-origins.md b/.claude/skills/logging/references/trace-origins.md deleted file mode 100644 index 3cfc8cc..0000000 --- a/.claude/skills/logging/references/trace-origins.md +++ /dev/null @@ -1,308 +0,0 @@ -# Trace Origins Across Client Runtimes - -Use this reference when logs or reported errors point to logger internals, -catch blocks, Query callbacks, queue flushes, RPC handlers, or minified bundles -instead of the code that initiated the operation. - -## Contents - -- [Keep Three Locations Distinct](#keep-three-locations-distinct) -- [Capture An Origin Portably](#capture-an-origin-portably) -- [Choose A Capture Policy](#choose-a-capture-policy) -- [Preserve Origins In Records](#preserve-origins-in-records) -- [Async And Framework Boundaries](#async-and-framework-boundaries) -- [Web](#web) -- [React Native](#react-native) -- [Electron And Electrobun](#electron-and-electrobun) -- [Production Symbolication](#production-symbolication) -- [Verification](#verification) - -## Keep Three Locations Distinct - -One diagnostic flow can contain three legitimate locations: - -1. **Failure stack**: where an `Error` was created or thrown. -2. **Operation origin**: where the caller initiated work that later crossed an - async, framework, worker, or RPC boundary. -3. **Observation location**: where a cache callback, reporter, transport, or - host received the result. - -The failure and operation origin are useful. The observation location is -usually infrastructure noise. Do not overwrite the first two with the third. - -For an ordinary log without an error, the operation origin is the logger call -site. For an error log, the original error stack remains primary and the logger -origin is supplemental. - -## Capture An Origin Portably - -`Error.stack` exists across common client engines, but its exact string format -and frame names differ. V8 exposes `Error.captureStackTrace`; Hermes and -JavaScriptCore compatibility must not be assumed. - -Use feature detection and keep the raw stack: - -```ts -export interface TraceOrigin { - readonly stack?: string -} - -type ErrorWithStack = { - stack?: string -} - -type ErrorConstructorWithCapture = ErrorConstructor & { - captureStackTrace?( - target: ErrorWithStack, - constructor?: (...args: never[]) => unknown, - ): void -} - -export function captureTraceOrigin(): TraceOrigin { - const target: ErrorWithStack = {} - const ErrorRuntime = Error as ErrorConstructorWithCapture - - if (typeof ErrorRuntime.captureStackTrace === "function") { - ErrorRuntime.captureStackTrace(target, captureTraceOrigin) - return { stack: target.stack } - } - - return { - stack: new Error("Operation origin").stack, - } -} -``` - -The fallback may retain the helper frame. That is better than deleting a fixed -number of lines and corrupting another engine's format. If a repository already -has a tested engine-specific frame filter, keep it at the presentation boundary, -not in the stored record. - -Capture before the discontinuity: - -```ts -const origin = captureTraceOrigin() - -queueMicrotask(() => { - runJob().catch(error => { - reportError(error, { originStack: origin.stack }) - }) -}) -``` - -Capturing inside `catch`, a queue flush, or an RPC handler only records that -observer. - -## Choose A Capture Policy - -Stack capture and source-map lookup have a cost. Do not add it blindly to every -high-volume production record. - -A practical starting policy is: - -- development: capture origins for every enabled level; -- production: capture for `warn`, `error`, and explicitly traced operations; -- error records: always preserve the original error object locally, regardless - of the origin policy; -- sampled or high-volume events: prefer stable scope and correlation identifiers - unless a measured investigation needs stacks. - -Make the policy configurable at the runtime composition root. Feature code must -not contain environment checks solely to decide whether a logger captures its -call site. - -The method-level options should also support an explicit opt-out: - -```ts -interface LogCallOptions { - readonly captureOrigin?: boolean - readonly originStack?: string -} -``` - -Use `captureOrigin: false` for infrastructure observers that have no genuine -earlier operation origin, such as a declarative Query cache callback, a global -error listener, an RPC ingestion handler, or a file writer. Their local stack -is an observation location, not a useful substitute for the caller. - -## Preserve Origins In Records - -Keep the record shape explicit: - -```ts -interface LogRecord { - readonly timestamp: number - readonly level: LogLevel - readonly message: string - readonly scope: LogFields - readonly context?: LogFields - readonly error?: unknown - readonly originStack?: string -} -``` - -Capture `originStack` at the public facade call, not inside `dispatch()` or a -transport. Allow an established reporter or operation wrapper to provide an -earlier origin captured before an async boundary. - -The distinction includes private facade helpers. If a public method delegates -automatic capture to a generic `emit()` or `resolveOrigin()` helper, that helper -becomes the leading frame. Capture directly while executing the public method, -then pass the raw stack into the private emitter. Do not trim the helper by a -fixed line count. - -Do not concatenate: - -```ts -// Avoid -error.stack += `\n--- called from ---\n${originStack}` -``` - -That mutates a potentially shared error, makes retries duplicate frames, and -produces a string that incident SDKs and source-map processors may parse -incorrectly. - -Local console and telemetry transports should receive the original `Error`. -At a serialization boundary, preserve only the standard diagnostic fields -needed by that boundary: - -```ts -interface WireLogRecord { - readonly errorText?: string - readonly errorStack?: string - readonly originStack?: string -} - -function toWireError(error: unknown) { - if (error instanceof Error) { - return { - errorText: `${error.name}: ${error.message}`, - errorStack: error.stack, - } - } - - return { - errorText: stringifyUnknownAtBoundary(error), - } -} -``` - -This is not a domain error serializer. Do not traverse causes, discover custom -properties, or maintain an error-class registry in logging infrastructure. -When extending a stored or wire schema, keep legacy fields readable until the -transport performs an explicit migration; new records can populate -`errorText`, `errorStack`, and `originStack` without rewriting old data. - -## Async And Framework Boundaries - -Modern engines can retain useful frames across some `await` chains, but timers, -event emitters, task queues, cache execution, workers, and RPC create real -discontinuities. Do not rely on one engine's current async-stack behavior as an -application contract. - -- Capture before scheduling a timer, background task, or queued callback when - the initiating caller matters. -- Give concurrent operations separate origins. Never store the latest origin in - one module-level or hook-level mutable variable. -- For TanStack Query, keep the rejection's original error. A stack captured - while defining a hook identifies registration, not necessarily the later - mutation invocation. A global MutationCache can capture synchronously in - `onMutate` and associate the origin with the Mutation instance; read the - TanStack Query reference below. -- For a React render failure, keep both the thrown error stack and React's - component stack; they answer different questions. -- Use safe operation or request identifiers to correlate logs across runtimes. - -Read `../../error-handling/references/tanstack-query.md` for reporting ownership -and mutation invocation details. - -## Web - -Pass an original `Error` as a value to the console transport instead of only -printing its message or preformatted stack string. Browser DevTools can then -inspect the actual error. - -The clickable location of a wrapped `console.*` call may still point to the -transport. Use the record's separately captured `originStack` when the facade -call site matters. Do not depend on DevTools-specific console formatting as the -only persisted diagnostic. - -Production bundles require source maps available to the chosen incident or log -inspection path. If public source maps are unacceptable, upload them privately -to the provider and remove or withhold deployed `.map` files according to the -build system's supported flow. - -## React Native - -Keep the original `Error` for the local console and incident SDK. Do not replace -it with `error.stack` before reporting; that turns the error into plain text and -can make the logger call look like the origin. - -React Native development tools and LogBox are development aids, not production -symbolication. Avoid private LogBox or Metro APIs for trimming or -symbolicating stacks inside application code. - -Hermes release stacks need the matching JavaScript/Hermes source maps. An OTA -update has a different JavaScript artifact from the embedded application build; -upload and identify maps for each update as well as each native build. - -## Electron And Electrobun - -Treat renderer and host stacks as separate runtime evidence. - -Renderer logging should send: - -- the renderer record and timestamp; -- renderer scope and safe correlation identifiers; -- minimal `errorText` and `errorStack`; -- the separately captured renderer `originStack`. - -The host persists those fields unchanged. It must not re-log the record and -replace the renderer origin with the ingestion handler. - -If a native handler itself fails, report the original failure in the host while -the host `Error` still exists. A renderer-facing RPC failure is a separate, -lossy contract and must not pretend to contain the host stack. Correlate both -sides with a safe request identifier when diagnosis needs the full path. - -For Electron main or another Node-based host, enable the runtime's supported -source-map integration before application modules load. Do not globally replace -`Error.prepareStackTrace` or rewrite stack strings unless the repository already -has a tested requirement for it. - -## Production Symbolication - -A captured stack is only an address list until it maps to the exact shipped -code. - -Verify: - -- source maps are generated for every minified or transpiled runtime; -- the release, build, distribution, and OTA update identifiers match the - uploaded artifacts; -- source maps are uploaded before using a synthetic production failure as a - test; -- private maps are not unintentionally published; -- a real test event resolves to original file, line, and function names; -- persisted raw stacks retain enough generated file, line, and column data for - later symbolication. - -Do not claim trace preservation is complete after a development-only console -test. - -## Verification - -Test at least: - -1. a synchronous thrown `Error`; -2. a caught and rethrown identical error; -3. a wrapper error with `cause`; -4. a rejection after an `await`; -5. a timer or queued task with a captured origin; -6. concurrent mutations or tasks with different origins; -7. a renderer record persisted by the host; -8. a host handler failure correlated with its renderer request; -9. a production or preview build symbolicated with its exact artifacts. - -Assert that the original error stack never gains appended sections after -retries, repeated logging, or transport delivery. diff --git a/.claude/skills/manage-project-work/SKILL.md b/.claude/skills/manage-project-work/SKILL.md deleted file mode 100644 index feb2a9d..0000000 --- a/.claude/skills/manage-project-work/SKILL.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -name: manage-project-work -description: "Classify and route project work through the central knowledge-backed workflow. Use when a requested feature, fix, refactor, migration, investigation, operational change, product decision, or architecture change may be significant; when resuming active work after interruption; or when a useful lightweight result has no active or curated owner yet. This is the default project-work router: it decides between lightweight work, a pending capture, a bounded change, and deliberate Wayfinder, then maintains the owning active checkpoint." ---- - -# Manage Project Work - -Choose the least expensive path that preserves important intent, progress, and -evidence. Operate `wfctl` yourself; the maintainer supplies product authority, -corrections, and review decisions rather than managing files or commands. - -## Route the request - -Treat work as significant when it may change observable behavior, domain -meaning, a contract, state, security, reliability, operations, architecture, -ownership, or cross-repository coordination. Size alone is not decisive. - -- **Lightweight:** clearly local and behavior-preserving. Work directly. Offer - a pending capture only when a non-obvious reusable result should survive and - no active change or curated concept already owns it. -- **Bounded significant change:** the outcome can be specified honestly now. - Start one central bundle and use `specify-project-change`. -- **Wayfinder:** a consequential destination is visible, but dependent product - or architecture choices make an honest spec impossible across one session. - Recommend `shape-project-direction`; enter only after maintainer agreement. - -If classification is genuinely ambiguous, explain the material risk, recommend -one route, and ask for the maintainer's choice. Do not force full ceremony onto -trivial work or hide a significant change as lightweight. - -## Start one canonical bundle - -Create the bundle before extended significant-task discussion so the reasoning -survives compaction: - -```sh -wfctl work start <slug> --title "<title>" --mode full|slice|wayfinder -``` - -Start from one leaf for single-repository delivery. Start from knowledge with -no leaf for project-only work, or repeat `--leaf` for known multi-repository -scope. Do not bind guessed leaves merely because Wayfinder may need them later. -Work that turns out to need one gains it later with `wfctl work bind <id>`, run -from that repository's own checkout. - -Run `wfctl work status <id>` and `wfctl work context <id> --stage shape`. -Use the returned bundle in knowledge for records and only the returned code -roots for implementation. Never create a competing spec or issue tracker in a -leaf. - -## Persist material change and discovery - -A turn is material whether it comes from the maintainer or the agent. It is -material when it changes a requirement, constraint, idea, alternative, -decision, rejection, deferral, scope boundary, evidence, risk, question, next -action, or the agent's understanding of the work. - -Before continuing, apply this preservation test: - -> If this newly learned information disappeared, could a fresh session repeat -> material investigation, choose differently, misunderstand the work, or act -> unsafely? - -If yes, append a complete entry to the owning record's `Discovery ledger`. -Record the observation, evidence or missing evidence, implication, applicable -scope or lifetime, and current disposition. Do not constrain discoveries to a -fixed taxonomy. Use the claimed issue during execution, `change.md` during -shaping/direct work/final review, or a linked artifact when the supporting -material is too large; the owning ledger must link that artifact. If the -discovery changes parent scope, acceptance, or decisions, update `change.md` -as well. Preserve invalidated discoveries with a corrected disposition rather -than erasing them. - -Then update the rest of the semantic record and checkpoint: - -1. append a concise proposed, approved, rejected, deferred, or superseded - decision-ledger entry when a choice changed; -2. update affected current state, scope, acceptance, issues, decisions, - progress, evidence, and discovery implications; -3. preserve rationale without copying the chat transcript or turning the - discovery ledger into an activity log; -4. run `wfctl work checkpoint <id>` for bundle-level work, or add - `--issue <issue-id>` for a claimed issue. Supply current state, last - completed action, exact next action, blockers, and actor. - -The checkpoint may mention a discovery ID and its effect on the frontier, but -must not duplicate the discovery. If useful material has no active or curated -owner, follow the pending-capture route instead. - -New bundle schemas require the ledger section. When an entry exists, workflow -context validates its stable `DISC-*` ID and non-empty observation, evidence, -implication, scope, and disposition. Fix malformed entries before continuing; -do not satisfy the gate with placeholders. - -The checkpoint hash binds the record after those edits. Never edit its YAML by -hand. If any owned record changes afterward, `wfctl work context` reports the -checkpoint stale and the agent must refresh it before claiming or closing work. - -After compaction, interruption, or a clean-session start, first run `wfctl work -context --stage resume` without an ID. It auto-selects only when exactly one -active record is bound to the current checkout. If none exists, do not invent -one. If several exist, run `wfctl work status`, present their human outcomes, -and ask the maintainer which one to resume; never guess from recency, branch, or -directory name. - -For the selected record, inspect status and the reported checkpoints, then -read every required file completely, including the entire discovery ledger, -before acting. Continue only in the exact reported code roots and existing -claim. If a binding or checkpoint is invalid, stop and reconcile it rather -than reconstructing state from chat memory. A checkpoint locates the frontier; -it never replaces the required full reads. - -If an upgraded legacy bundle has no structured checkpoint, read its current -record and former Progress/Handoff sections completely, then run `wfctl work -checkpoint` once to adopt the new model. Preserve the old prose as lineage, but -do not maintain a second resume state afterward. - -If a pre-ledger bundle has no `Discovery ledger`, do not fabricate past -discoveries. Add the section when material work next changes that owner and -preserve new discoveries from that point forward; old bundle versions remain -readable for compatibility. - -## Route the active bundle - -- Use `shape-project-direction` only for deliberate Wayfinder. -- Use `specify-project-change` to synthesize bounded work or collapse a clear - map into stable acceptance criteria. -- Use `split-project-change` when approved work needs several dependency-aware - sessions. -- Use `implement-work-item` for exactly one frontier issue. A small bounded - change may be implemented directly from `change.md` after framing approval, - while preserving the same workspace and progress rules. -- Use `verify-project-work` for complete file accounting, spec/implementation - reconciliation, drafting the curated pages, closure, and promotion. - -A framing is recorded with `wfctl work approve <id> --stage framing --by -human:<maintainer-id>`, and a promotion with `wfctl work promote <id> --by -human:<maintainer-id>`, never by editing `maintainer_review` directly. Record -what they answered in the session with `--attested "<their words>" --session -"<where>"`; a typed confirmation and an out-of-band `--token` remain for a -maintainer who wants a receipt you could not have written. A hand-written receipt -fails the completion gate. Closure itself asks them nothing — that is the tool's -to check, and a completion approval is required only where delivery no longer -matches the framing they approved. - -`changes/active/<id>/` is already the knowledge-side living record. There is -no final dump into `raw/`. Closure moves the entire bundle intact to -`changes/promotion/<id>/` while its drafted pages wait on the maintainer, and to -`changes/archive/<id>/` once they land or once there are none. - -## Pending capture - -Do not create a capture when an active change, issue, intake case, -reconstruction, or curated concept already owns the material; update that owner -and its checkpoint instead. When useful lightweight material genuinely has no -owner and the maintainer accepts retaining it, run: - -```sh -wfctl work capture add <slug> --title "<fact to retain>" -``` - -Complete the returned pending capture. It remains non-authoritative until the -knowledge agent routes it to a real destination or discards it through -`wfctl work capture resolve`. Never copy active progress into `changes/inbox/` -and never cite raw or intake material as current truth. diff --git a/.claude/skills/manage-project-work/agents/openai.yaml b/.claude/skills/manage-project-work/agents/openai.yaml deleted file mode 100644 index b83903f..0000000 --- a/.claude/skills/manage-project-work/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Manage Project Work" - short_description: "Route work and preserve resumable context" - default_prompt: "Use $manage-project-work to classify or resume this request, preserve consequential discoveries in their semantic owner, and refresh the owning checkpoint last." diff --git a/.claude/skills/manage-project-work/assets/bundle-review.md b/.claude/skills/manage-project-work/assets/bundle-review.md deleted file mode 100644 index bd0545e..0000000 --- a/.claude/skills/manage-project-work/assets/bundle-review.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -workflow_version: 1 -kind: bundle-review -updated_at: "{{UPDATED_AT}}" -files: [] ---- - -# Bundle review - -This ledger is maintained by `wfctl`. Each receipt accounts for one complete -bundle file at an exact content hash. It is not proof of semantic correctness. diff --git a/.claude/skills/manage-project-work/assets/capture.md b/.claude/skills/manage-project-work/assets/capture.md deleted file mode 100644 index 6c82f9d..0000000 --- a/.claude/skills/manage-project-work/assets/capture.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -capture_version: 1 -kind: capture -id: "{{CAPTURE_ID}}" -title: "{{TITLE}}" -status: pending -created_at: "{{CREATED_AT}}" -awaits: agent -source: {} -claim_refs: [] -resolution: null ---- - -# Summary - -Record the reusable result, proposal, limitation, or observation. Separate -verified facts, maintainer intent, and inference. - -# Evidence - -Record exact source paths, revisions, commands, outputs, and known limits. For -intake or reconstruction material, retain fully qualified claim references in -`claim_refs`; never cite raw files as authority. - -# Who answers this - -State `awaits: maintainer` when the capture holds something only they can -settle — intent nobody recorded, a decision to confirm or revoke, a route -between materially different options. State `awaits: agent` when it is material -someone still has to place. A question filed as triage is a question nobody -knows they were asked. - -# Why retained - -Explain why this material may affect a future product or engineering decision -despite not belonging to active work or curated knowledge yet. - -# Suggested route - -Recommend one next route: discard it, curate verified truth, or start/link a -normal significant change. The capture remains non-authoritative while it is -pending in `changes/inbox/`. diff --git a/.claude/skills/manage-project-work/assets/wayfinder-map.md b/.claude/skills/manage-project-work/assets/wayfinder-map.md deleted file mode 100644 index 61f9844..0000000 --- a/.claude/skills/manage-project-work/assets/wayfinder-map.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -workflow_version: 1 -kind: wayfinder-map -status: charting -destination: "" -notes: [] -resolved: [] -fog: [] -out_of_scope: [] -created_at: "{{CREATED_AT}}" -updated_at: "{{CREATED_AT}}" ---- - -# Destination - -Name what reaching the end of this map makes possible. This is a planning -destination, not an instruction to implement it during Wayfinder. - -# Standing notes - -Record domain, constraints, accepted vocabulary, and skills or sources every -session must consult. - -# Resolved route - -`wfctl` maintains a one-line named pointer for each completed Wayfinder issue. -The issue remains the only home of its full resolution. - -# Not yet specified - -Keep in-scope fog that cannot yet be phrased as a precise question. Remove a -fog item when it becomes an issue or is ruled out of scope. - -# Out of scope - -Record boundaries outside this destination and the reason for each boundary. diff --git a/.claude/skills/manage-project-work/assets/work-issue.md b/.claude/skills/manage-project-work/assets/work-issue.md deleted file mode 100644 index df4086f..0000000 --- a/.claude/skills/manage-project-work/assets/work-issue.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -workflow_version: 3 -kind: work-issue -id: "{{ISSUE_ID}}" -title: "{{TITLE}}" -phase: delivery -type: delivery -status: draft -blocked_by: [] -satisfies: [] -repositories: [] -artifacts: [] -claim: null -resolution: null -created_at: "{{CREATED_AT}}" -updated_at: "{{CREATED_AT}}" -checkpoint_version: 1 -checkpoint: - status: ready - stage: implement - actor: system:wfctl - current_state: Issue is ready but unclaimed. - last_completed: Issue record created. - next_action: Read the required context and claim the issue. - blockers: [] - updated_at: "{{CREATED_AT}}" - basis_sha256: "{{CHECKPOINT_BASIS}}" ---- - -# Outcome - -State the complete behavior, decision, or fact this issue must deliver. Keep it -small enough for one fresh agent session when possible. - -# Acceptance contribution - -Explain how this issue contributes to its `satisfies` acceptance IDs. For a -Wayfinder issue, state the precise question it resolves instead. - -# Constraints and boundaries - -Record relevant project knowledge, approved decisions, repository scope, and -explicit exclusions. Do not copy the parent specification. - -# Discovery ledger - -Preserve newly learned information when losing it could make a future session -repeat material investigation, choose differently, misunderstand the work, or -act unsafely. Do not use this as a chronological activity log or restrict it to -a predefined class of findings. - -Append one durable block per discovery, replacing the placeholders: - -```markdown -## DISC-NNN — Concise title - -- **Observation:** What was learned and its uncertainty. -- **Evidence:** Direct basis or missing evidence. -- **Implication:** What this changes. -- **Scope:** Where and for how long it applies. -- **Disposition:** Its current owner or next destination in plain language. -``` - -Preserve an invalidated entry and update its disposition rather than deleting -it. - -# Verification - -Record direct source inspection, executable checks, outcomes, and limitations. -Graph or search output is navigation evidence, not implementation proof. diff --git a/.claude/skills/manage-project-work/assets/work-spec.md b/.claude/skills/manage-project-work/assets/work-spec.md deleted file mode 100644 index 23118d1..0000000 --- a/.claude/skills/manage-project-work/assets/work-spec.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -workflow_version: 5 -id: "{{WORK_ID}}" -title: "{{TITLE}}" -mode: "{{MODE}}" -status: shaping -scope: leaf -created_at: "{{CREATED_AT}}" -updated_at: "{{CREATED_AT}}" -checkpoint_version: 1 -checkpoint: - status: active - stage: shape - actor: system:wfctl - current_state: Initial framing is pending. - last_completed: Central work bundle created. - next_action: Persist the first agreed framing and refresh this checkpoint. - blockers: [] - updated_at: "{{CREATED_AT}}" - basis_sha256: "{{CHECKPOINT_BASIS}}" -repositories: [] -acceptance: [] -direction: - status: bounded - map: "" - resolved_at: "" -knowledge_alignment: - reviewed: [] - conflicts: [] - # Written by `wfctl knowledge decided "<subject>" --record <id>`, never by hand: - # a check the record claims is one the command actually ran. - decided: null - # Set covered: false with a basis when no curated concept covers this work. - # An empty knowledge base is a supported state; an invented concept path is not. -graph_evidence: - queries: [] -knowledge_promotion: - # Written by `wfctl work promotion <id>`, from the pages drafted under this - # bundle's promotion/ directory. Draft each at the path it will occupy inside - # knowledge/; nothing enters the corpus until the maintainer approves it. - status: "" - concepts: [] - drafts: [] - reason: "" -maintainer_review: - framing: - status: pending - by: "" - at: "" - notes: [] - # Asked only when delivery no longer matches the approved framing. - completion: - status: pending - by: "" - at: "" - notes: [] - # Written by `wfctl work promote <id>`, which writes the pages in the same act. - promotion: - status: pending - by: "" - at: "" - notes: [] -verification: - result: pending - revision: "" - worktree_id: "" - repositories: [] - acceptance: [] - acceptance_reviewed: false - implementation_reviewed: false - knowledge_reviewed: false - checks: [] - unresolved: [] ---- - -# Summary - -State the intended outcome and why it matters. - -# Current state - -Maintain the latest agreed problem, desired outcome, constraints, scope, -assumptions, and risks. Rewrite this section whenever the current understanding -changes; keep resumable execution state only in the structured checkpoint. - -# Direction map - -For a broad initiative, record the destination, current landscape, affected -Areas and actors, constraints, success signals, and explicit non-goals. For a -bounded task, state `Not required — bounded change` with a short reason. - -# Domain language - -Record proposed canonical terms, definitions, accepted aliases, names to -avoid, and their resolution state. Keep unresolved vocabulary here; promote it -to current knowledge only after approval. - -# Decision frontier - -List only unresolved choices whose answers can materially change product -meaning, scope, architecture, ownership, or the next safe action. Rank them by -leverage. For a bounded task with no frontier, state `None`. - -# Uncertainty and fog - -Record unknown facts, missing authority, contradictions, dependencies, and -risks. For each item, state its impact and what would resolve it. - -# Open questions - -- Record unresolved questions that can change the solution, authority, or scope. - -# Discussion and decision ledger - -Append one concise entry after every material maintainer turn. Preserve rejected, -deferred, and superseded directions instead of rewriting history. - -| At | Status | Subject | Outcome and rationale | -| --- | --- | --- | --- | -| {{CREATED_AT}} | proposed | Initial framing | Awaiting discussion. | - -# Current behavior and evidence - -Record Graphify queries, the source locations they led to, and direct inspection -of the actual code. Graph output is navigation evidence, never the authority for -an implementation claim. Add supplementary text-search findings only after -graph analysis. - -# Discovery ledger - -Preserve newly learned information when losing it could make a future session -repeat material investigation, choose differently, misunderstand the work, or -act unsafely. This is an information-acquisition ledger, not an activity log and -not a closed list of "findings" categories. - -Append one durable block per discovery, replacing the placeholders: - -```markdown -## DISC-NNN — Concise title - -- **Observation:** What was learned, including uncertainty when unverified. -- **Evidence:** Direct basis, or the evidence that is still missing. -- **Implication:** What this changes for understanding or action. -- **Scope:** Where it applies and any lifetime or invalidation condition. -- **Disposition:** What now owns it or must happen next, in plain language. -``` - -Preserve superseded or disproven entries and update their disposition instead -of deleting the path by which the team learned. - -# Knowledge alignment - -List relevant vision, product, architecture, decision, repository, and uncertainty concepts. Record conflicts and maintainer resolutions. - -# Scope - -## In - -- Define included behavior. - -## Out - -- Define explicit exclusions. - -# Decisions - -- Record the current approved decisions with enough rationale to guide - implementation. Link each entry to its ledger history when it evolved. - -# Plan and progress - -- [ ] Add concrete implementation and validation steps. - -# Acceptance criteria - -- [ ] Add observable, testable completion criteria. - -# Verification evidence - -Record fresh commands, results, directly inspected code at the bound revision, -and criterion-by-criterion inspection. A passing test suite or Graphify result -alone is not complete evidence. - -# Knowledge promotion - -List the curated concepts updated by this change, or explain why the completed -change does not alter durable project intent, meaning, decisions, contracts, -boundaries, ownership, or operational knowledge. Promote only claims verified -against authoritative sources; untrusted raw intake is never provenance. - -# Deviations and unresolved work - -State deviations, remaining risks, placeholders, mocks, follow-ups, or `None`. diff --git a/.claude/skills/native-integration/SKILL.md b/.claude/skills/native-integration/SKILL.md deleted file mode 100644 index f4460a0..0000000 --- a/.claude/skills/native-integration/SKILL.md +++ /dev/null @@ -1,345 +0,0 @@ ---- -name: native-integration -description: Use whenever work adds, changes, reviews, or debugs desktop native integration in Electron, Electrobun, or another host/webview runtime. Trigger for Electron main-process, preload, contextBridge, ipcMain/ipcRenderer, IPC channels, Electrobun BrowserView/Electroview RPC, native modules, handler definitions, typed renderer clients, method or event registries, capability metadata or catalogs, runtime validation schemas, host registration, filesystem or OS access, deep links, secure storage, updates, and exposing native operations to automation, agents, or MCP. ---- - -# Native Integration - -Treat Electron, Electrobun, and similar desktop runtimes as transport adapters -around the same modular native-RPC architecture: - -```text -shared contract - -> handler definitions - -> native feature module - -> central host registry - -> runtime transport - -> typed renderer client - -handler definitions - -> capability catalog - -> optional automation, agent, or MCP consumers -``` - -The renderer is sandboxed. The host owns privileged work. A typed, validated, -inspectable native registry is the boundary between them. - -First inspect the repository's existing native-RPC foundation and one complete -feature module. Extend that pattern. Do not introduce a parallel raw IPC system. - -## Start From A Feature Module, Not A Channel - -A native integration is a feature with a contract, handlers, metadata, and a -client. It is not merely an IPC string. - -Examples include: - -- filesystem and operating-system access; -- secure storage and keychain operations; -- windows, dialogs, notifications, and shell operations; -- deep links and application lifecycle; -- application updates; -- local services and native addons. - -Each feature owns its contract and runtime implementation. The application -composes features through one registry. Transport-specific wiring must not -become the owner of feature behavior. - -## Prefer Package-Owned Native Features - -If a native feature can be isolated behind a coherent contract, place it in -`packages/`. This is the default, not an optimization reserved for features -already shared by multiple applications. - -The package should own: - -- its shared contract and method registry; -- its handler definitions and host implementation; -- its typed renderer client; -- its runtime-specific entrypoints when required. - -The application should only compose and register the package. Keep a feature -inside an application only when it is genuinely inseparable from that -application. - -An illustrative package may expose surfaces such as: - -```text -packages/{capability}/ -└── src/ - ├── index.ts - └── native/ - ├── index.ts - ├── client.ts - ├── main/ - │ └── index.ts - └── bun/ - └── index.ts -``` - -This is not a mandatory literal tree. Use only the runtime entrypoints the -project needs. Apply **file-structure** recursively to decide exact placement, -capability subfolders, public entrypoints, and local internals. - -Shared and renderer entrypoints must never import host-only dependencies. - -## Define One Shared Contract - -Declare every callable method once in a feature-owned registry: - -```ts -export const nativeMethods = { - chooseDirectory: "workspace:choose-directory", - readMetadata: "workspace:read-metadata", -} as const; -``` - -The contract must provide: - -- a stable, feature-namespaced wire method; -- the request parameters for that method; -- the response for that method; -- runtime schemas for both request and response. - -Prefer deriving TypeScript types from the schemas or another existing source of -truth. Do not separately hand-maintain method strings, DTOs, schemas, host -signatures, and renderer signatures. - -Runtime validation of both parameters and responses is mandatory at the native -boundary. An exception is acceptable only when the established transport -already guarantees the exact runtime-validated contract or the value cannot -meaningfully be represented by the project's schema system. Document that -exception beside the definition. `void` input or output is still an explicit -contract, not an omitted one. - -Do not create a generic `types.ts` dump. Keep each contract with the capability -that owns it. - -## Define Inspectable Handlers - -Every request handler definition should carry enough information to register, -validate, execute, inspect, and expose it without reconstructing knowledge -elsewhere: - -- a stable name; -- its method from the shared registry; -- parameter and response schemas; -- an execution policy, including timeout behavior; -- a host-context factory or explicit dependencies; -- capability metadata. - -Conceptually: - -```ts -const chooseDirectory = defineNativeHandler({ - name: "chooseDirectory", - method: nativeMethods.chooseDirectory, - params: chooseDirectoryParams, - response: chooseDirectoryResponse, - execution: { timeoutMs: 30_000 }, - capability: { - id: nativeMethods.chooseDirectory, - title: "Choose workspace directory", - description: "Opens the native directory picker.", - exposure: false, - requiresApproval: true, - }, - createContext: createWorkspaceContext, - handle: async ({ params, context }) => { - // Privileged feature logic belongs here or in an injected service. - }, -}); -``` - -Names and helper signatures are illustrative. Reuse the repository's native-RPC -primitives instead of recreating this API. - -Validate at the boundary. Keep privileged logic in the handler or an injected -host service, never in preload code or renderer code. - -Use bounded timeouts by default. Disable a timeout only for an operation whose -lifecycle is legitimately open-ended, such as waiting for a native user prompt, -and make that decision explicit. - -## Make Capabilities Universal And Exposure Explicit - -Every registered handler must include capability metadata even when its only -current consumer is the renderer. - -At minimum, metadata should make these facts discoverable: - -- stable capability id; -- human-readable title and description; -- safety or effect classification used by the project; -- whether explicit approval is required; -- whether the capability may be exposed outside the ordinary renderer client. - -Exposure is opt-in. The default must be `false`, `none`, or the repository's -equivalent. Registration makes a handler callable by the trusted application -client; it does not automatically make it available to automation, agents, or -MCP. - -Derive the capability catalog from handler definitions and their schemas. Do -not maintain a second manual registry. Automation, an agent, MCP, a command -palette, or another future consumer may read the catalog, but none of them owns -the native architecture. - -Operations with destructive effects, external side effects, sensitive data, or -arbitrary native-tool execution should require approval according to the -project's policy. - -## Build A Native Feature Module - -A native module aggregates the feature boundary: - -- module name; -- method registry; -- request map; -- handler loader or handler map; -- handler definitions and capability metadata; -- typed renderer client. - -The module must not know which application will register it. Host-only handlers -may load lazily to keep native dependencies out of shared and renderer bundles. - -Registration should fail clearly for duplicate methods, missing handlers, or a -contract that cannot be validated. Do not silently allow incomplete modules. - -## Keep One Reviewable Host Registry - -The host application should have one central registry that composes all native -modules. Adding an isolated feature should require one obvious registry entry, -not edits across unrelated switches and transport files. - -The registry should be able to: - -- combine request contracts; -- load and dispatch handlers by method; -- enumerate handler definitions and capability metadata; -- detect duplicate or missing registrations. - -Register the complete host surface before the renderer or webview can call it. -Application-owned registry code is wiring only; feature logic remains in its -package. - -## Generate A Thin Renderer Client - -Build the renderer client from the same request map and method registry used by -the host. - -The renderer: - -- imports a client-safe entrypoint such as `native/client`; -- calls feature methods, not raw transport channels; -- never imports host entrypoints, native addons, or `node:*`; -- never repeats method strings or request/response types; -- guards native-only access when the project also runs in a browser, test, or - Storybook environment. - -Keep transport errors in their native form unless the project already defines a -specialized error registry or conversion flow. Follow that established flow -when it exists. Never invent a new normalization layer inside a feature client. - -## Adapt Only The Transport - -The feature module and contract remain conceptually the same across runtimes. -Only the adapter changes. - -| Runtime | Host adapter | Renderer boundary | -| --- | --- | --- | -| Electron | Register the combined request map through `ipcMain.handle` or the repository's wrapper | Expose one narrow preload bridge through `contextBridge`; the typed feature client invokes it | -| Electrobun | Register the combined schema and handlers through the project's BrowserView RPC wrapper | Construct the typed client through Electroview RPC or the repository's wrapper | - -For Electron, prefer one shared bridge surface over a new `window.*` global for -every feature. Preload forwards validated typed calls; it contains no feature -business logic. - -For Electrobun, keep Bun-only handlers behind the Bun runtime entrypoint and -webview-safe client code behind the client entrypoint. - -Follow the exact runtime API and versions already installed in the repository. -Do not make feature packages depend on transport details that belong in the -adapter. - -## Treat Requests And Events As Separate Contracts - -Request-response is the default for commands and queries: send typed parameters -and receive one typed result. - -Events are first-class for host-pushed information such as: - -- deep-link navigation; -- update or download progress; -- native lifecycle changes; -- long-running operation progress; -- notifications produced outside a renderer request. - -Define a typed event registry and payload map rather than scattering event -strings. Every renderer subscription must return an unsubscribe function that -removes the exact listener it registered. - -Do not emulate request-response with ad hoc event pairs. Do not emulate a native -event stream with polling or repeated invokes. - -How a component subscribes and manages its lifecycle remains a React concern; -follow **components**, including its strict guidance on avoiding `useEffect`. - -## Preserve The Privilege Boundary - -- The renderer never accesses the filesystem, OS, native addons, keychain, or - host lifecycle directly. -- Validate untrusted request parameters before privileged work and validate the - result before returning it across the boundary. -- Prefer narrow operations over arbitrary primitives: for example, a specific - trusted URL operation instead of unrestricted shell execution. -- Inject host context such as windows, services, paths, and application metadata - rather than importing ambient global state throughout handlers. -- Follow the project's existing logging and error policy. Do not leak secrets, - sensitive native details, or arbitrary host error payloads across the bridge. - -## Implementation Workflow - -1. Inspect the native-RPC foundation, central registry, and one complete native - feature already present in the repository. -2. Decide whether the feature can be isolated into a package. Prefer a package - whenever it can. -3. Define the method registry, request map, and runtime schemas. -4. Implement handler definitions with execution policy, host context, and - capability metadata. -5. Aggregate them into a native feature module. -6. Derive or build the typed renderer client from the same contract. -7. Add one explicit entry to the central host registry. -8. Add a typed event contract only when the host must push information. -9. Test contract validation, handler behavior, registry completeness, client - typing, and subscription cleanup as applicable. -10. Run the repository's focused formatting, linting, type-checking, tests, and - native build checks. - -## Avoid - -- scattered raw `ipcMain`, `ipcRenderer`, or RPC method strings; -- a separate preload global for every feature; -- duplicate request/response types on opposite sides of the boundary; -- handlers without parameter or response validation; -- registered handlers without capability metadata; -- exposing registered handlers to agents, MCP, or automation by default; -- large application-level switches that own feature dispatch; -- renderer imports from host runtime entrypoints; -- host imports leaking into shared or client bundles; -- keeping an isolatable native feature in an application instead of `packages/`; -- a generic native, handlers, or types dumping ground. - -## Related Skills - -- Exact package and feature-internal placement, visibility, and entrypoints → - **file-structure**. -- Native client errors and external-service access → **api-integration**. -- Secure persisted state consuming a native provider → **state-management**. -- Native deep links entering the route tree → **routing**. -- Host and renderer diagnostic delivery → **logging**. -- Native failure propagation, exception reporting, and runtime boundaries → - **error-handling**. -- Native-originated user notifications and recovery presentation → - **user-feedback**. -- Formatting, linting, type-checking, tests, and native build checks → - **code-quality**. diff --git a/.claude/skills/qmd/SKILL.md b/.claude/skills/qmd/SKILL.md deleted file mode 100644 index 0d4b048..0000000 --- a/.claude/skills/qmd/SKILL.md +++ /dev/null @@ -1,295 +0,0 @@ ---- -name: qmd -description: Search local markdown knowledge bases, notes, docs, and wikis with QMD. Use when users ask to find notes, retrieve documents, inspect a wiki, answer from indexed markdown, or set up QMD access. -license: MIT -compatibility: Requires qmd CLI or MCP server. Install via `npm install -g @tobilu/qmd`. -metadata: - author: tobi - version: "2.2.0" -allowed-tools: Bash(qmd:*), mcp__qmd__* ---- - -# QMD - Query Markdown Documents - -## How search works - -QMD searches local markdown collections: notes, docs, wikis, transcripts, and -project knowledge bases. Use it before web search when the answer may already be -in indexed local files. - -The workflow is always: - -1. Search for candidate documents. -2. Retrieve the full source with `qmd get` or `qmd multi-get`. -3. Answer from retrieved text, citing paths or docids. - -Do not answer from snippets alone when the user needs facts, decisions, quotes, -or nuance. Snippets are only leads. - -Typical loop: - -```bash -qmd search "merchant reality support interviews" -n 5 -# leads: #abc123 concepts/customer-proximity.md; #def432 sources/merchant-call.md -qmd multi-get "#abc123,#def432" --format md -``` - -**Default to structured `qmd query` with `intent:`, `lex:`, `vec:`, and `hyde:` -fields that you write yourself.** You are a better query expander than the -built-in model: you know the user's actual goal, the domain vocabulary, and the -nearby-but-wrong concepts to avoid. Do not just paste the user's words into -`qmd query "..."` and hope the expansion model guesses right — supply the -`intent:` and craft the lexical and semantic terms deliberately (see -[Pick the right search mode](#pick-the-right-search-mode)). - -When reporting what you retrieved, a compact note is enough; do not paste whole -files unless needed: - -```text -Retrieved: -- #abc123 concepts/customer-proximity.md -- #def432 sources/merchant-call.md -``` - -## Pick the right search mode - -Use **BM25 lexical search** when you know exact words, titles, names, code -symbols, or rare phrases: - -```bash -qmd search "cockpit OKR Goodhart" -n 10 -qmd search '"AI Before Headcount"' -c concepts -n 5 -``` - -Use **`qmd query` with structured fields** when the user describes an idea -indirectly, uses different wording than the source, or needs conceptual recall. -**This is the default mode — write the fields yourself rather than leaning on -query expansion.** Combine exact anchors with semantic recall: - -```bash -qmd query $'intent: Find the concept note about metrics as instruments without letting OKRs replace judgment.\nlex: cockpit instruments OKR Goodhart metrics judgment\nvec: data informed not metric driven product judgment\nhyde: A concept note says metrics are useful like cockpit instruments, but leaders should remain data-informed rather than metric-driven because OKRs and dashboards can Goodhart product judgment.' -``` - -Structured query fields (you author each one — do not delegate this to the -expansion model): - -- `intent:` states what you are trying to find **and what to avoid**. Always - supply this. It steers ranking away from nearby-but-wrong concepts. -- `lex:` exact terms, aliases, titles, code symbols, and rare words you expect - in the source. This is your own keyword expansion. -- `vec:` paraphrases the idea in natural language, in source-like wording. -- `hyde:` describes the document or answer that would satisfy the request. - -You do not need all four every time, but you should almost always write at least -`intent:` plus one of `lex:`/`vec:`. A bare `qmd query "the user's sentence"` -throws away the context only you have and relies on the built-in expander to -reconstruct it — prefer the structured form. - -If you genuinely have nothing to expand (a single rare token, a verbatim phrase), -that is a job for `qmd search`, not bare `qmd query`: - -```bash -qmd query --format json --explain $'intent: ...\nlex: ...\nvec: ...' # inspect ranking -``` - -If `qmd query` is slow or model/GPU setup fails, fall back to `qmd search` with -better lexical terms. - -## Retrieve sources - -Search results include docids like `#abc123` and `qmd://...` paths. Fetch them: - -```bash -qmd get "#abc123" -qmd get qmd://concepts/ai-before-headcount.md -qmd multi-get "#abc123,#def432" --format md -qmd multi-get 'concepts/{ai-before-headcount.md,data-informed-not-metric-driven.md}' --format md -qmd multi-get 'sources/podcast-2025-*.md' -l 80 -``` - -Use `multi-get` when comparing several hits or gathering context across pages. - -### Output is line-numbered and carries the docid — cite both - -`get` and `multi-get` are **line-numbered by default** and always print the -document's `#docid` and `qmd://` path. So `get` output looks like: - -```text -qmd://concepts/note.md #abc123 ---- - -1: # Metrics as instruments -2: -3: Treat dashboards like cockpit instruments... -``` - -Cite the docid and exact line numbers in your answer, and use the numbers to ask -for the next slice. Pass `--no-line-numbers` only when you need raw content to -copy verbatim (e.g. reproducing a code block). - -When you need to open or edit the underlying file (e.g. hand a path to `Read`, -`Edit`, or an editor), add `--full-path`. It replaces the `qmd://` URL + docid -header with the document's on-disk path, falling back to the canonical header if -the file no longer exists on disk: - -```text -$ qmd get "#abc123" --full-path -/Users/you/notes/concepts/note.md ---- - -1: # Metrics as instruments -``` - -`--full-path` works the same way on `qmd search` and `qmd query`: result paths -become the file's on-disk path — `./`-prefixed relative path when the file is -inside `$PWD`, absolute realpath otherwise — and the per-result `#docid` is -dropped because the path is the identifier. The leading `./` is intentional so -the output is unambiguously a filesystem path and cannot be mistaken for a bare -collection-relative string. Default search/query output still uses `qmd://` -URIs; only opt into `--full-path` when you specifically need a path you can hand -to a non-QMD tool. - -### Read line ranges with the `:from:count` suffix — never pipe through `sed`/`head`/`tail` - -`qmd get` slices files itself. Use the suffix or flags; do **not** shell out to -`sed -n`, `head`, `tail`, or `awk` to pull a line range. Piping defeats docid -resolution, virtual-path lookups, line numbering, and the header, and it is -slower and more error-prone. - -The most compact form is a `:from:count` suffix right on the path or docid — -prefer it: - -```bash -qmd get "#abc123:120:40" # 40 lines starting at line 120 -qmd get qmd://concepts/note.md:200:60 # lines 200–259 -qmd get "#abc123:120" # from line 120 to end of file -qmd get "#abc123" --from 120 -l 40 # equivalent, using flags -``` - -Suffix and flags: - -- `<path>:<from>:<count>` — start at line `<from>`, read `<count>` lines. **Best - for reading around a search hit.** -- `<path>:<from>` — start at `<from>`, read to end of file. -- `--from <line>` / `-l <lines>` — flag equivalents. Explicit flags override the - suffix, so `... :5:2 -l 1` reads 1 line. -- `--no-line-numbers` — drop the `N:` prefixes (line numbers are on by default). - -Wrong: `qmd get "#abc123" | sed -n '120,160p'` -Right: `qmd get "#abc123:120:40"` - -Search results include a `:line` anchor on each hit — feed it straight into -`qmd get path:line:<n>` to read a window around the match (line numbers in the -output will start at `line`). - -## Discover what is indexed - -```bash -qmd collection list -qmd ls -qmd status -``` - -Add collection filters when broad searches drift into the wrong corpus: - -```bash -qmd search "headcount autonomous agents" -c concepts -n 10 -qmd query "merchant support product reality" -c concepts -c sources -n 10 -``` - -Omit `-c` to search everything. - -## MCP Tool: `query` - -When using the MCP server, prefer structured searches: - -```json -{ - "searches": [ - { "type": "lex", "query": "cockpit OKR Goodhart" }, - { "type": "vec", "query": "data informed not metric driven product judgment" }, - { "type": "hyde", "query": "A concept note explains that metrics are useful as instruments, but leaders should not let OKRs or dashboards replace judgment." } - ], - "intent": "Find the concept note about using metrics as instruments without becoming metric-driven.", - "collections": ["concepts"], - "limit": 10 -} -``` - -Query types: - -- `lex` — BM25 keyword search. Best for exact terms, names, titles, and code. -- `vec` — vector semantic search. Best for natural-language concepts. -- `hyde` — vector search using a hypothetical answer/document passage. - -## Query craft - -Good QMD searches mix three things: - -1. **Title/alias anchors:** exact page titles, named entities, phrases. -2. **Semantic paraphrase:** how a human would describe the idea. -3. **Negative space:** enough intent to avoid nearby-but-wrong concepts. - -Examples: - -```bash -# Exact-ish title lookup -qmd search '"arm the rebels" merchants tools big companies' -c concepts - -# Semantic concept lookup -qmd query $'intent: Find the customer proximity concept, not generic customer delight.\nlex: support pseudonymous merchant customer interviews\nvec: founder stays close to merchant reality through support and product use' - -# Source lookup -qmd search "six-week cadence WhatsApp merchant relationships Shawn Ryan" -c sources -n 10 -``` - -## Setup and maintenance - -Only mutate indexes when the user asked for setup or maintenance. Searching and -retrieving are safe; collection/index mutation is not a casual first step. - -```bash -npm install -g @tobilu/qmd -qmd collection add ~/notes --name notes -qmd update -qmd embed -``` - -Health and diagnostics: - -```bash -qmd doctor -qmd status -qmd pull -``` - -`qmd doctor` checks config, model cache, device/GPU setup, vector fingerprints, -and common environment overrides. If a model-backed command fails, run it before -changing configuration. - -## MCP setup - -See `references/mcp-setup.md` for Claude Code, Claude Desktop, OpenClaw, and HTTP -server configuration. - -## Pitfalls - -- **Do not stop at snippets.** Fetch documents before making claims. -- **Do not slice files with `sed`/`head`/`tail`.** Use the `path:from:count` - suffix (e.g. `qmd get "#abc123:120:40"`) or `--from`/`-l`. Output is already - line-numbered; piping breaks docid resolution, the header, and virtual paths. -- **Do not lean on query expansion.** Write `intent:`/`lex:`/`vec:`/`hyde:` - yourself. A bare `qmd query "user sentence"` discards the context only you - have. You expand the query; the model just ranks. -- **Do not overuse semantic search.** If you know exact titles or terms, BM25 is - faster and often better. -- **Do not mutate indexes casually.** `qmd collection add`, `qmd update`, and - `qmd embed` change local state and can be expensive. -- **Model-backed commands can be environment-sensitive.** If `qmd query`, - `qmd vsearch`, or reranking fails because local models/GPU are unavailable, - use `qmd search` and stronger lexical/structured terms. -- **Ambiguous user wording needs intent.** Add `intent:` rather than hoping query - expansion guesses the right domain. -- **Collection names matter.** Search `concepts` for synthesized wiki pages, - `sources` for transcripts/raw source pages, and docs collections for code or - project documentation. diff --git a/.claude/skills/qmd/references/mcp-setup.md b/.claude/skills/qmd/references/mcp-setup.md deleted file mode 100644 index 5d32a62..0000000 --- a/.claude/skills/qmd/references/mcp-setup.md +++ /dev/null @@ -1,102 +0,0 @@ -# QMD MCP Server Setup - -## Install - -```bash -npm install -g @tobilu/qmd -qmd collection add ~/path/to/markdown --name myknowledge -qmd embed -``` - -## Configure MCP Client - -**Claude Code** (`~/.claude/settings.json`): -```json -{ - "mcpServers": { - "qmd": { "command": "qmd", "args": ["mcp"] } - } -} -``` - -**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`): -```json -{ - "mcpServers": { - "qmd": { "command": "qmd", "args": ["mcp"] } - } -} -``` - -**OpenClaw** (`~/.openclaw/openclaw.json`): -```json -{ - "mcp": { - "servers": { - "qmd": { "command": "qmd", "args": ["mcp"] } - } - } -} -``` - -## HTTP Mode - -```bash -qmd mcp --http # Port 8181 -qmd mcp --http --daemon # Background -qmd mcp stop # Stop daemon -``` - -## Tools - -### structured_search - -Search with pre-expanded queries. - -```json -{ - "searches": [ - { "type": "lex", "query": "keyword phrases" }, - { "type": "vec", "query": "natural language question" }, - { "type": "hyde", "query": "hypothetical answer passage..." } - ], - "limit": 10, - "collection": "optional", - "minScore": 0.0 -} -``` - -| Type | Method | Input | -|------|--------|-------| -| `lex` | BM25 | Keywords (2-5 terms) | -| `vec` | Vector | Question | -| `hyde` | Vector | Answer passage (50-100 words) | - -### get - -Retrieve document by path or `#docid`. - -| Param | Type | Description | -|-------|------|-------------| -| `path` | string | File path or `#docid` | -| `full` | bool? | Return full content | -| `lineNumbers` | bool? | Add line numbers | - -### multi_get - -Retrieve multiple documents. - -| Param | Type | Description | -|-------|------|-------------| -| `pattern` | string | Glob or comma-separated list | -| `maxBytes` | number? | Skip large files (default 10KB) | - -### status - -Index health and collections. No params. - -## Troubleshooting - -- **Not starting**: `which qmd`, `qmd mcp` manually -- **No results**: `qmd collection list`, `qmd embed` -- **Slow first search**: Normal, models loading (~3GB) diff --git a/.claude/skills/routing/SKILL.md b/.claude/skills/routing/SKILL.md deleted file mode 100644 index e46a726..0000000 --- a/.claude/skills/routing/SKILL.md +++ /dev/null @@ -1,330 +0,0 @@ ---- -name: routing -description: Use whenever work adds, changes, moves, debugs, or reviews application routes, route or page directories, URL paths, path or search params, navigation, redirects, layouts, outlets, guards, loaders, route-tree composition, router context or history, deep-link mapping, not-found behavior, or routed Storybook/test harnesses. Also use when deciding where a route or page belongs, even if the requested change is described only as a file move or a new screen. ---- - -# Routing - -Model application navigation as one explicit, typed tree. Keep URL contracts, -route declarations, page components, and tree composition separate enough that -each has one owner and the import graph stays acyclic. - -## Inspect The Existing Router First - -Before changing routes: - -1. Find the router entrypoint, root route, top-level branches, guards, and - not-found handling. -2. Trace the concrete parent chain of the affected page. -3. Inspect how the project validates params/search, loads route data, lazy-loads - components, and constructs links. -4. Check platform constraints such as browser, hash, or memory history and any - deep-link adapter. -5. Preserve a coherent local router convention unless this task explicitly - corrects or migrates it. - -Do not infer routing from directory names alone. The router definition is the -runtime source of truth. - -## Keep The Route Tree At The Source Root - -Prefer one `routes/` directory at the application source root: - -```text -src/ -├── api/ -├── routes/ -│ ├── __root.tsx -│ ├── router.tsx -│ ├── guards.ts -│ ├── app/ -│ ├── auth/ -│ └── local-auth/ -└── ui/ -``` - -Do not nest this root under an application branch and produce shapes such as -`app/routes/app`. `routes/app`, `routes/auth`, and `routes/local-auth` are clear -siblings in one navigation tree. - -These directories represent routing scopes, not necessarily literal URL -segments. For example, `routes/app` may be a pathless authenticated layout with -an internal id, while `routes/auth` may own the real `/auth` segment. Choose -branch names for the boundary they own; define the URL explicitly in the route. - -If the framework mandates filesystem routing, adapt the filenames it requires -while preserving the ownership and composition rules in this skill. - -## Treat URLs As Resource Contracts - -- Use stable, resourceful paths: `/projects` and `/projects/$projectId`. -- Put resource identity in path params. -- Put filters, sorting, tabs, pagination, and view options in search params. -- Keep transient component state outside the URL only when it has no navigation, - sharing, refresh, or history meaning. -- Treat import/export formats and persisted files as data contracts, not routes. -- Define redirects deliberately; do not use them to conceal an incoherent tree. - -A page directory mirrors ownership in the route tree, but it does not create a -route by filesystem magic unless the selected router explicitly works that way. - -## Use Three Deliberate Directory Shapes - -### Layout or guard branch - -Use a route-owning branch when descendants share a layout, guard, or URL segment: - -```text -routes/app/pages/Projects/ -├── index.tsx # ProjectsLayout with Outlet -├── route.tsx # /projects -├── route.tree.ts # List route + Item subtree -├── components/ # shared by project routes -└── pages/ - ├── List/ - │ ├── index.tsx # project list page - │ └── route.tsx # /projects - └── Item/ - ├── index.tsx # ProjectLayout with Outlet - ├── route.tsx # /projects/$projectId - ├── route.tree.ts # index redirect + item subpages - ├── components/ # shared by one project's subpages - └── pages/ - ├── Overview/ - │ ├── index.tsx - │ └── route.tsx # /projects/$projectId/overview - └── Workspace/ - ├── index.tsx - └── route.tsx # /projects/$projectId/workspace -``` - -`Projects/index.tsx` is a layout because `List` and `Item` are its route -children. `Item/index.tsx` repeats the same role one level deeper because -`Overview` and `Workspace` are its children. The pattern is recursive: any leaf -may become a layout boundary when it gains real child pages. - -Use contextual page names inside an established owner. `Projects/pages/Item` -means the selected project route; keep `ProjectItem` for a list row/card -component and `projectId` for the route parameter. Name rendered components by -their UI role, such as `ProjectsLayout`, `ProjectListPage`, `ProjectLayout`, and -`ProjectOverviewPage`. - -### Page with direct subpages - -When a page is not a resource collection and has no selected-id level, omit the -artificial `List` and `Item` layers: - -```text -routes/app/pages/Project/ -├── index.tsx # ProjectLayout with Outlet -├── route.tsx # /project + explicit index route -├── route.tree.ts -└── pages/ - ├── Overview/ - │ ├── index.tsx - │ └── route.tsx # /project/overview - ├── Workspace/ - │ ├── index.tsx - │ └── route.tsx # /project/workspace - └── Settings/ - ├── index.tsx - └── route.tsx # /project/settings -``` - -Define what happens at `/project` explicitly. It may redirect to `Overview`, -`Workspace`, `Settings`, or another existing child, but there is no universal -default: choose the destination at this boundary from the product's business -logic. Never infer it from child order or silently treat the first registered -route as the default. - -Keep the redirect local and visible: - -- use an owned index route declaration when the decision is available from - router context, a guard, a loader, or static product policy; -- render a named index-forwarder component when the decision requires - render-time state or hooks; -- let `route.tree.ts` register that index route with the subpages, but keep the - redirect decision out of tree-composition code; -- keep the ordinary layout component focused on shared UI and its `Outlet`. - -### Terminal page without subpages - -Treat `Overview`, `Workspace`, and `Settings` in the preceding example as leaf -pages: each is a terminal route with no child routes, `Outlet`, or -`route.tree.ts`. A leaf page normally owns: - -- `index.tsx`: the page component only; -- `route.tsx`: the route declaration, schema, guard/loader when leaf-specific, - and lazy component binding. - -Private page components, hooks, and other implementation go inward under the -same page boundary according to **file-structure**. When a leaf gains real -subpages, promote it to the layout-branch shape and apply the same structure -recursively. - -For example, keep components used only by `Overview` inside that page: - -```text -Overview/ -├── index.tsx -├── route.tsx -└── components/ - ├── ProjectSummary.tsx - └── ActivityPanel/ - ├── index.tsx - └── components/ - └── ActivityRow.tsx -``` - -`routing` owns the `Overview` page boundary. **file-structure** owns everything -inside it: place each component, hook, schema, or other implementation at the -narrowest boundary containing all of its consumers, and repeat that rule -recursively. - -### Structural group without a route - -When sibling pages need grouping but share no URL segment, guard, or layout, do -not invent a wrapper route: - -```text -routes/auth/pages/PasswordRecovery/ -├── route.tree.ts -└── pages/ - ├── RequestReset/ - │ ├── index.tsx - │ └── route.tsx - └── ResetPassword/ - ├── index.tsx - └── route.tsx -``` - -Its `route.tree.ts` exports the sibling route collection for the nearest real -parent to compose. A directory is not entitled to a route merely because it -exists. - -## Separate Declaration, Rendering, And Composition - -Give each routing file one job: - -- `route.tsx` declares only routes owned by that boundary. It imports its - concrete parent and lazy-loads the page/layout component. It never imports or - assembles descendant routes. -- `index.tsx` renders only the page or layout. A layout renders an `Outlet`; a - leaf renders its screen. It does not assemble the route tree. -- `route.tree.ts` imports sibling/descendant route objects and composes them with - their parent. It contains no screen UI, schemas, data lookup, or route-specific - branching. -- `router.tsx` attaches top-level branch trees to the root and owns global router - configuration. - -This keeps the structural import graph flowing child declaration -> concrete -parent declaration, then composition -> children. Parent declarations never -import their children, so page components can safely import their own route -object for typed hooks. - -Do not use barrels to aggregate routes. Import the exact `route` or `route.tree` -module so parentage and ownership remain visible. - -## Assemble Recursively - -Compose each subtree at the narrowest boundary that owns all of its children, -then pass one tree or a small route collection upward: - -```text -router.tsx -└── app/route.tree.ts - └── Projects/route.tree.ts - ├── List/route.tsx - └── Item/route.tree.ts - ├── route.tsx # optional item index redirect - ├── Overview/route.tsx - └── Workspace/route.tsx -``` - -Do not centralize every leaf in `router.tsx`. Do not declare child paths in a -parent component. Do not widen typed route collections to a generic route type -merely to make composition compile; preserve inferred route identities. - -Routing-specific placement is authoritative over **file-structure** for route -branches, pages, declarations, and tree assembly. Apply **file-structure** -recursively inside the selected page or layout boundary. - -## Put Behavior At The Narrowest Route Boundary - -- Put a guard on the nearest ancestor whose entire subtree shares the rule. -- Put a loader on the route that owns the navigation dependency. Reuse the - project's API query definitions and cache instead of building a second fetch - or cache policy inside the loader. -- Put a search schema beside the leaf that reads it. -- When sibling leaves consume the same search contract, put it in a small module - at their nearest shared boundary; do not force it into a parent declaration - that would create a parent-child import cycle. -- Keep global router context and history selection at the router root. -- Treat platform history choices as adapters: browser history is not universally - correct, and hash history is not universally correct. - -Reuse the project's established error, auth, preload, cache, and redirect flows. -Do not invent a parallel routing policy inside one page. - -## Read Route State Through The Route Contract - -Prefer the route object's typed params/search hooks when the router supports -them. Avoid handwritten route ids, string casts, and generic APIs that discard -which route owns a value. - -Pathless layouts can contribute internal ids without contributing URL segments. -Therefore an internal route id may differ from the visible path. Code and routed -test harnesses must preserve that distinction. - -Use a loose/non-strict route-state read only for genuinely route-agnostic shared -behavior, and accept the reduced per-route typing intentionally. - -## Navigate Semantically - -- Use the router's `Link` for user-initiated navigation so accessibility, - history, preloading, and modifier-click behavior remain intact. -- Use programmatic navigation for event outcomes and side-effect redirects such - as successful submission, authentication changes, or guard resolution. -- Construct destinations with typed `to`, `params`, and `search` values rather - than concatenating URL strings. -- Keep sidebar/header navigation separate from route registration. A valid route - does not automatically belong in primary navigation. -- Map platform deep links into the same route contracts. Keep transport and - operating-system event handling in the platform integration boundary instead - of creating a second navigation tree. - -## Preserve Real Routes In Tests And Stories - -Pages that read route-owned params or search values need an active match with -the same effective route id and parent chain. A flat memory router around the -component is insufficient when pathless ancestors contribute ids. - -Build the smallest parallel route chain that preserves the production ids, then -provide page chrome as a shell. Do not weaken production route typing or add -fallback params merely to make a story render. - -## Verify The Contract - -After a routing change: - -1. Search for stale paths, route ids, imports, links, redirects, deep-link - mappings, and navigation entries. -2. Typecheck the registered route tree without generic widening or casts. -3. Exercise direct entry, refresh, back/forward history, links, and - programmatic redirects. -4. Verify valid and invalid path/search params, guards, loaders, and not-found - behavior. -5. Run affected routed tests and stories, then the repository's required - lint/tests/build. - -Read [references/tanstack-router.md](references/tanstack-router.md) when the -project uses TanStack Router, when pathless layout ids are involved, or when a -routed Storybook/test harness must reproduce production route identity. - -## Skill Boundaries - -- Route loader queries and cache ownership → **api-integration**. -- Page and layout component implementation → **components**. -- Non-routing placement inside a page boundary → **file-structure**. -- Native deep-link transport and event handling → **native-integration** or - the project's platform integration skill. diff --git a/.claude/skills/routing/agents/openai.yaml b/.claude/skills/routing/agents/openai.yaml deleted file mode 100644 index 706ce38..0000000 --- a/.claude/skills/routing/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Application Routing" - short_description: "Own route contracts, page trees, and navigation" - default_prompt: "Use $routing to design or change application routes, page hierarchy, guards, params, navigation, or route-tree composition." diff --git a/.claude/skills/routing/references/tanstack-router.md b/.claude/skills/routing/references/tanstack-router.md deleted file mode 100644 index aac46e1..0000000 --- a/.claude/skills/routing/references/tanstack-router.md +++ /dev/null @@ -1,343 +0,0 @@ -# TanStack Router Patterns - -Use these examples only when the project uses TanStack Router. The directory and -ownership rules remain applicable with other routers, but their APIs will differ. - -## Contents - -- Root and top-level branches -- Pathless guarded branches -- Recursive route layouts -- Direct subpage layouts and landing redirects -- Typed leaf state -- Recursive composition -- Shared search contracts -- Routed Storybook and tests - -## Root And Top-Level Branches - -Keep the root route, router creation, and branch assembly at `src/routes/`: - -```tsx -// routes/__root.tsx -export const rootRoute = createRootRouteWithContext<RouterContext>()({ - component: RootLayout, -}) - -// routes/router.tsx -const routeTree = rootRoute.addChildren([appTree, authTree, catchAllRoute]) - -export const router = createRouter({ - routeTree, - context: { queryClient }, - defaultPreload: 'intent', -}) - -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } -} -``` - -The exact router options are project decisions. For example, a desktop -application loaded from a file protocol may need hash history, while a hosted -web application normally uses browser history. Copy the boundary, not another -project's platform choice. - -## Pathless Guarded Branch - -An authenticated application branch may be pathless: - -```tsx -// routes/app/route.tsx -export const appLayoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: 'app', - beforeLoad: requireAuthenticated, - component: lazyRouteComponent(() => import('./index')), -}) - -export const appIndexRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/', - component: lazyRouteComponent(() => import('./pages/Home')), -}) -``` - -```tsx -// routes/app/index.tsx -export default function AppLayout() { - return ( - <AppShell> - <Outlet /> - </AppShell> - ) -} -``` - -The `app` id participates in internal route identity but not in the visible URL. -Do not assume the route id of `/projects/$projectId` is equal to that visible -path when it descends from the pathless branch. - -## Recursive Route Layouts - -Give `/projects` a layout route when both its list and selected item are child -routes: - -```tsx -// routes/app/pages/Projects/route.tsx -export const projectsRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/projects', - component: lazyRouteComponent(() => import('./index')), -}) - -// routes/app/pages/Projects/pages/List/route.tsx -export const Route = createRoute({ - getParentRoute: () => projectsRoute, - path: '/', - component: lazyRouteComponent(() => import('./index')), -}) - -// routes/app/pages/Projects/pages/Item/route.tsx -export const projectItemRoute = createRoute({ - getParentRoute: () => projectsRoute, - path: '$projectId', - component: lazyRouteComponent(() => import('./index')), -}) - -export const projectItemIndexRoute = createRoute({ - getParentRoute: () => projectItemRoute, - path: '/', - beforeLoad: ({ params }) => { - throw redirect({ - to: '/projects/$projectId/overview', - params, - }) - }, -}) -``` - -```tsx -// routes/app/pages/Projects/index.tsx -export default function ProjectsLayout() { - return <Outlet /> -} - -// routes/app/pages/Projects/pages/Item/index.tsx -import { projectItemRoute } from './route' - -export default function ProjectLayout() { - const { projectId } = projectItemRoute.useParams() - - return ( - <ProjectProvider projectId={projectId}> - <Outlet /> - </ProjectProvider> - ) -} -``` - -`Projects` owns the `/projects` segment and shared project-area layout. `List` -is its index child. `Item` owns `$projectId` and becomes another layout because -it has `Overview` and `Workspace` children. If `Item` has no child pages, omit -its `route.tree.ts` and `pages/`; its `index.tsx` is then the leaf screen. - -An item index may render `Overview` at path `/`, or redirect -`/projects/$projectId` to an explicit `overview` child. Choose one canonical URL -and define that index behavior in the item route boundary. - -## Direct Subpage Layouts And Landing Redirects - -Omit collection/item layers when the route is an ordinary page with direct -subpages: - -```tsx -// routes/app/pages/Project/route.tsx -export const projectRoute = createRoute({ - getParentRoute: () => appLayoutRoute, - path: '/project', - component: lazyRouteComponent(() => import('./index')), -}) - -export const projectIndexRoute = createRoute({ - getParentRoute: () => projectRoute, - path: '/', - component: lazyRouteComponent( - () => import('./index'), - 'ProjectIndexForwarder', - ), -}) -``` - -```tsx -// routes/app/pages/Project/index.tsx -export default function ProjectLayout() { - return <Outlet /> -} - -export function ProjectIndexForwarder() { - const canOpenWorkspace = useCanOpenWorkspace() - - return ( - <Navigate - replace - to={canOpenWorkspace ? '/project/workspace' : '/project/overview'} - /> - ) -} -``` - -```tsx -// routes/app/pages/Project/route.tree.ts -import { Route as projectOverviewRoute } from './pages/Overview/route' -import { Route as projectSettingsRoute } from './pages/Settings/route' -import { Route as projectWorkspaceRoute } from './pages/Workspace/route' -import { projectIndexRoute, projectRoute } from './route' - -export const projectTree = projectRoute.addChildren([ - projectIndexRoute, - projectOverviewRoute, - projectWorkspaceRoute, - projectSettingsRoute, -]) -``` - -The index route is mandatory when the layout route itself has no screen. The -destination is not prescribed: select an existing child from local business -rules. Use an inline redirect or route lifecycle function when the choice is -static or available before render. Use a named index-forwarder component when -the choice requires React state or hooks. In both cases, keep the redirect -explicit and keep `route.tree.ts` limited to composition. - -## Typed Leaf State - -Keep each leaf route object at module level and let its component import it: - -```tsx -// routes/app/pages/Projects/pages/Item/pages/Overview/route.tsx -const searchSchema = z.object({ - tab: z.enum(['summary', 'history']).optional().catch('summary'), -}) - -export const Route = createRoute({ - getParentRoute: () => projectItemRoute, - path: 'overview', - validateSearch: searchSchema, - component: lazyRouteComponent(() => import('./index')), -}) -``` - -```tsx -// routes/app/pages/Projects/pages/Item/pages/Overview/index.tsx -import { Route } from './route' - -export default function ProjectOverviewPage() { - const { projectId } = Route.useParams() - const { tab } = Route.useSearch() - - return <ProjectOverview projectId={projectId} tab={tab} /> -} -``` - -Do not replace this with `getRouteApi('<guessed-id>')`, -`useParams({ from: '<guessed-id>' })`, or a cast. A pathless ancestor can make a -guessed id wrong at runtime even when weakened types let it compile. - -`useSearch({ strict: false })` is acceptable only in route-agnostic shared code -such as a generic URL-filter hook. It trades route-specific guarantees for -portability. - -## Recursive Composition - -A route declaration imports its concrete parent. A tree module imports children: - -```tsx -// routes/app/pages/Projects/pages/Item/route.tree.ts -import { Route as overviewRoute } from './pages/Overview/route' -import { Route as workspaceRoute } from './pages/Workspace/route' -import { projectItemIndexRoute, projectItemRoute } from './route' - -export const projectItemTree = projectItemRoute.addChildren([ - projectItemIndexRoute, - overviewRoute, - workspaceRoute, -]) -``` - -```tsx -// routes/app/pages/Projects/route.tree.ts -import { projectItemTree } from './pages/Item/route.tree' -import { Route as projectListRoute } from './pages/List/route' -import { projectsRoute } from './route' - -export const projectsTree = projectsRoute.addChildren([ - projectListRoute, - projectItemTree, -]) -``` - -For an organizational group with no route of its own, export the leaves as a -collection and spread them into the real parent: - -```tsx -// routes/auth/pages/PasswordRecovery/route.tree.ts -export const passwordRecoveryRoutes = [ - requestResetRoute, - resetPasswordRoute, -] - -// routes/auth/route.tree.ts -export const authTree = authRoute.addChildren([ - signInRoute, - ...passwordRecoveryRoutes, -]) -``` - -Do not annotate these values as `AnyRoute[]`. Generic widening erases the route -tree information that powers typed ids, params, search, links, and navigation. -Let inference preserve the concrete tuple/array types. - -Avoid generic factories such as -`createProjectRoutes<TParentRoute extends AnyRoute>(parent)`. They obscure the -real parent and make route identity easier to widen accidentally. Import the -concrete parent route instead. - -## Shared Search Contracts - -A search schema belongs to the route that reads it. If two sibling routes share -the same external query contract, place it in a sibling module: - -```text -routes/auth/ -├── search-schema.ts -└── pages/ - ├── AcceptInvite/route.tsx - └── SignUp/route.tsx -``` - -Both leaves import the schema. The parent `route.tsx` does not import from its -children, so the route graph stays acyclic. - -Validate search values from external URLs and deep links as untrusted input. -Choose strict, coercing, defaulting, or catch behavior according to the actual -contract; do not silently normalize values merely because another project did. - -## Routed Storybook And Tests - -`Route.useParams()` and `Route.useSearch()` resolve against the active match's -effective id. When the production page is below `id: 'app'`, a flat test route -with only the visible path does not provide the same match. - -Create a minimal parallel chain: - -```text -test root -└── pathless layout (id: app) - └── leaf (same visible full path) -``` - -The route objects need not be identical; the effective route-id chain must -match. Render shared application chrome around the routed outlet as test/story -shell content. This preserves production page code and its typed hooks. diff --git a/.claude/skills/setup-workflow-environment/SKILL.md b/.claude/skills/setup-workflow-environment/SKILL.md deleted file mode 100644 index fc6d370..0000000 --- a/.claude/skills/setup-workflow-environment/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: setup-workflow-environment -description: Install, update, repair, or explain the shared project workflow environment in a clean or initialized knowledge repository or leaf source repository. Use when a maintainer asks to bootstrap wfctl, connect a repository to project knowledge, update workflow rules or skills, preserve existing AGENTS.md or CLAUDE.md instructions, or diagnose a broken workflow installation. ---- - -# Setup Workflow Environment - -Install through deterministic `wfctl` operations while preserving all unowned consumer content. - -## Command ownership - -Run `wfctl init`, `wfctl upgrade`, and `wfctl check` yourself when terminal -access permits. Ask the maintainer for repository kind, paths, skill targets, -scope, and conflict decisions, not routine command execution. Provide a manual -command only when bootstrapping without `wfctl`, missing authority, or tool -access prevents execution; state that blocker explicitly. - -Treat `wfctl init knowledge`, `wfctl init leaf`, and repository-local `wfctl -upgrade` as the normal optional maintainer-facing CLI entry points, and all may -be delegated to this skill. `--target`, `check`, `knowledge`, `work`, QMD, and -Graphify operations belong to the agent unless the maintainer explicitly -requests manual or automation-oriented instructions. - -## Procedure - -1. Confirm that Bun and `wfctl` are available. If `wfctl` is missing, stop and - tell the maintainer how to install or link the canonical Bun package. -2. Confirm `qmd --version` reports at least `2.5.3`. If it is missing or old, - ask for installation authority and run - `bun install -g @tobilu/qmd@2.5.3`. QMD is the supported knowledge - retrieval engine; do not substitute a custom indexer. -3. Before leaf initialization, require both the `graphify` CLI and the official - native Graphify skill in the current session. Do not require either for a - knowledge repository that is not inspecting source. If the CLI is absent, - ask for user-level installation authority and run `uv tool install - graphifyy`. If the native skill is absent, ask for authority and run - `graphify install --platform <agent>` once for every selected agent - platform. Then tell the maintainer to restart the agent and stop: an on-disk - skill is not active in the current session. After restart, verify both - requirements and repeat `wfctl init leaf`. Knowledge retrieval and raw - intake use QMD, not Graphify. -4. Identify whether the target is a `knowledge` or `leaf` repository. - A knowledge target may be a new directory without Git: ask the maintainer - for authority to initialize it and pass `--init-git`. In an interactive - terminal, `wfctl` asks this itself. A leaf must already be an existing Git - repository; never use `--init-git` to turn an arbitrary leaf path into one. -5. For a leaf repository, obtain the local knowledge-repository path. Do not guess it. -6. Inspect existing `AGENTS.md`, `CLAUDE.md`, `.claude/rules`, `.agents/skills`, and `.claude/skills`, including symlink targets. -7. Run `wfctl init <knowledge|leaf> --target <path>` with `--knowledge <path>` - for a leaf. Use the default project skill scope unless the maintainer chooses - user scope or no skill installation. The dependency preflight must pass - before the command writes files. A successful leaf initialization must also - run `graphify update .` from the exact target checkout; do not accept a - checkout whose local graph was never built. Preserve the existing root - `.gitignore` while ensuring it excludes `graphify-out/`. Successful leaf - init must also register durable repository identity in knowledge and add - this exact worktree to ignored local state. It must not change that - repository's active reconstruction selection. -8. Review the preview before confirming. For each conflict, preserve the - existing content, accept the offered per-file backup and replacement, or - stop. Never invent a blanket overwrite. -9. Let `wfctl` update managed instruction and guide blocks. If their markers - are malformed or duplicated, stop and repair them with the maintainer rather - than replacing the whole file. Use `wfctl init <kind> - --print-instructions agents|guide` to obtain the exact managed text. -10. Confirm that `PROJECT_WORKFLOW.md` preserves pre-existing text outside the - managed block. -11. Confirm the installed skills include the version-matched official `qmd` - skill from `qmd skills path qmd` for every selected agent. New skills are - not active in the current session automatically; tell the maintainer to - restart the agent session before knowledge-dependent work. - Confirm `explore-project-knowledge` is installed for both profiles so - project discovery works identically from knowledge and leaf sessions. - Confirm `shape-project-direction`, `specify-project-change`, - `split-project-change`, and `implement-work-item` are installed for both - profiles. For a knowledge profile, also confirm - `research-project-context` is installed. -12. For a knowledge profile, confirm that `.qmd/index.yml` defines separate - `knowledge`, `changes`, `intake`, `reconstruction`, and `raw` collections. - Confirm that `reconstruction/active` and `reconstruction/archive` exist and - `reconstruct-project-knowledge` is installed. `wfctl init` - builds `.workflow/current/knowledge-graph.json` plus - `.workflow/current/claim-ledger.json` and runs `qmd update`, so explicit - knowledge navigation, claim-lineage audit, and BM25 retrieval must be - ready immediately. Treat - `qmd-models` and `qmd-embeddings` warnings as optional semantic setup, not - as lexical-index success. Ask before `qmd pull` or `qmd embed`; the current - model set is roughly 2 GB. -13. Run `wfctl check --target <path>` and report every failure and warning. For - a leaf, confirm `graphify-graph` passes and refers to this checkout, not a - sibling repository or another worktree, and confirm `graphify-ignore` - passes. Confirm `repository-connection` identifies this exact known - checkout and reports whether it is selected as the default, awaiting - selection, or registered as an alternative for reconstruction. - For a knowledge repository, report registered repositories, known - worktrees, and explicit default reconstruction selections. Deferred - selection is healthy during setup and must not be reported as a warning. - Do not select during - initialization; `reconstruct-project-knowledge` owns contextual selection - when reconstruction is actually requested. -14. For a knowledge profile, run `wfctl knowledge validate` and - `wfctl knowledge build`; do not create a raw intake case unless intake - processing was requested. -15. Point the maintainer to `PROJECT_WORKFLOW.md`; it defines their review - responsibilities for both profiles. - -Use `wfctl upgrade` for an existing installation. A workflow 0.3 knowledge -repository requires a content migration after the generated assets upgrade: - -1. inventory every existing curated concept without changing its meaning; -2. route stakeholder current behavior to `curate-product-knowledge`; -3. route technical realization to `curate-engineering-knowledge`; -4. split any mixed concept and connect the two views with explicit links; -5. preserve decision lineage, provenance, delivery state, and uncertainty; -6. invoke `verify-knowledge-quality` and record fresh quality plus normal - verification receipts for the final content hash; -7. run `wfctl knowledge validate` and `wfctl knowledge build`. - -Never infer a view, audience, or product meaning merely to silence an upgrade -error. Leave the concept draft and ask the maintainer when authority is -missing. Read [the installation contract](references/install-contract.md) when -handling unusual files or symlinks. - -## Safety - -- Preserve text outside `wfctl` markers. -- Never replace an existing file or directory with a symlink. -- Treat local edits to owned generated files as conflicts. -- Do not claim Codex consumes `.claude/rules`; Codex receives the routing contract through `AGENTS.md`. -- Let the pinned `skills` CLI install both workflow skills and QMD's - version-matched native skill for the selected agent targets and scope. Do not - hand-copy skills around it. diff --git a/.claude/skills/setup-workflow-environment/agents/openai.yaml b/.claude/skills/setup-workflow-environment/agents/openai.yaml deleted file mode 100644 index e922f25..0000000 --- a/.claude/skills/setup-workflow-environment/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Setup Workflow Environment" - short_description: "Install or repair wfctl environments" - default_prompt: "Use $setup-workflow-environment to install or repair this repository workflow safely." diff --git a/.claude/skills/setup-workflow-environment/references/install-contract.md b/.claude/skills/setup-workflow-environment/references/install-contract.md deleted file mode 100644 index 9a087bb..0000000 --- a/.claude/skills/setup-workflow-environment/references/install-contract.md +++ /dev/null @@ -1,140 +0,0 @@ -# Installation contract - -## Instruction files - -- Create `AGENTS.md` when absent. -- Update only the block between `<!-- wfctl:begin -->` and `<!-- wfctl:end -->`. -- When `CLAUDE.md` is absent, link it to `AGENTS.md`. -- When `CLAUDE.md` is a regular file, preserve it and maintain a separate marked block. -- Treat malformed, duplicated, broken, or externally targeted managed symlinks as conflicts. - -## Skills - -- Delegate skill placement to the pinned `skills` CLI. -- Use the installer's copy mode for every selected agent. Do not create - cross-agent symlinks between `.agents/skills` and `.claude/skills`. -- Default to project scope; use user scope or no installation only when the - maintainer chooses it. -- Install `setup-workflow-environment`, `analyze-with-graphify`, and the - version-matched official `qmd` skill for both profiles. -- Install `operate-project-knowledge` only for the knowledge profile as the - default router for explanation, history, audit, navigation, contradiction, - and triage requests. -- Install `explore-project-knowledge` for both profiles. It gives knowledge and - leaf sessions the same read-only progressive product-discovery path through - the configured knowledge repository without requiring user knowledge of - Areas, capabilities, or files. -- Install `process-raw-intake` only for the knowledge profile. -- Install `reconstruct-project-knowledge` only for the knowledge profile. -- Install `research-project-context` only for the knowledge profile. -- Install `shape-project-direction` for both profiles so an unbounded - significant initiative can resolve its Wayfinder map inside the same central - bundle before implementation starts. -- Install `specify-project-change`, `split-project-change`, and - `implement-work-item` for both profiles so specification synthesis, - dependency-aware issue creation, and exact-worktree execution are explicit - reusable modes rather than hidden conventions. -- Install `curate-project-knowledge` for both profiles because a leaf agent - must promote durable truth before closing significant work. -- Install `curate-product-knowledge`, `curate-engineering-knowledge`, and - `verify-knowledge-quality` for both profiles. The orchestrator depends on - them to keep stakeholder and technical views separate and to bind independent - authority/truth and reader-communication review to the current content hash. -- Install alignment, work management, and verification skills for both - profiles. Knowledge needs them for project-only and multi-repository bundles; - a project-only bundle has no implementation checkout. -- Select Codex, Claude, or both explicitly. -- Let the pinned installer update an already owned selected skill in - non-interactive mode. Remove only obsolete project-scope workflow skills - whose lock entry still identifies this package; never remove an unowned - skill by name. - -The workflow skill `analyze-with-graphify` is a routing and policy gate, not a -copy of Graphify's native skill. Require the `graphify` CLI and the official -native `graphify` skill supplied by that tool whenever source code must be -analyzed. Verify native-skill availability against the current session -catalog; an on-disk file alone does not prove the running agent loaded it. -When the CLI is absent, ask for user-level installation authority and run -`uv tool install graphifyy`. Install the native skill with `graphify install ---platform <agent>` for every selected platform, then restart the session. -`graphify install --help` is the authority for currently supported platform -names. Do not report setup success until the restarted session exposes the -native skill. -Do not require Graphify for Markdown intake or OKF curation that does not inspect -source code. - -Run `graphify update .` from the exact leaf checkout after applying an -initialization or upgrade. This graph is worktree-local evidence infrastructure: -do not reuse a sibling checkout's `graphify-out`, and do not report setup -success when the update command fails or the resulting graph has no nodes. -Maintain a `# wfctl:begin` / `# wfctl:end` block in the root `.gitignore` when -no existing exact `graphify-out/` rule already covers the artifact. Preserve -every pre-existing ignore rule. - -The QMD skill is not maintained as a workflow copy. Resolve its source with -`qmd skills path qmd`, then let the same pinned `skills` CLI copy it to the -selected Codex and Claude targets. This preserves agent-target and scope -semantics while keeping the skill matched to the installed QMD version. - -## Knowledge retrieval - -- Require QMD `>=2.5.3` for both profiles because leaf alignment reads the - linked knowledge repository and skill discovery depends on - `qmd skills path qmd`. -- Install the supported baseline through Bun when authorized: - `bun install -g @tobilu/qmd@2.5.3`. -- For a knowledge profile, let `wfctl` own `.qmd/index.yml` and - `.qmd/.gitignore`. -- Keep QMD's database and model cache out of Git. The index is disposable and - rebuildable from repository content. -- Include only `knowledge` in unscoped searches. Require explicit collection - selection for `changes`, `intake`, `reconstruction`, and `raw`. -- Run QMD from the knowledge root so it uses the project-local index. -- Run `qmd update` during knowledge initialization and upgrade so lexical BM25 - retrieval is ready before success is reported. -- Diagnose `qmd status` and `qmd doctor` separately. A working lexical index is - required. Missing models or stale embeddings are warnings until semantic or - hybrid retrieval is needed. - -## Knowledge and claim relationship graphs - -- Compile authored Markdown links and workflow relation metadata with - `wfctl knowledge build`; do not infer semantic relationships. -- Store the generated knowledge artifact at - `.workflow/current/knowledge-graph.json` and the explicit intake/ - reconstruction claim ledger at `.workflow/current/claim-ledger.json`; both - are already ignored. -- Build both during a valid knowledge initialization or upgrade. -- Make `wfctl check` fail when either artifact is missing, invalid, or stale. -- Keep QMD responsible for retrieval and Graphify responsible for source-code - structure. The compiled graphs only represent authored knowledge links and - explicit claim lineage. - -## Rules - -- Install readable copies under `.workflow/rules`. -- Mirror namespaced rule files under `.claude/rules`. -- Route Codex to `.workflow/rules` through the managed `AGENTS.md` block. - -## Maintainer guide - -- Install `PROJECT_WORKFLOW.md` as a managed, visible root document for both - profiles, using a managed block that preserves text outside the markers. -- Render profile-specific content and the configured knowledge path. -- Treat malformed or duplicated managed markers as a conflict. -- Stop for maintainer-controlled repair when markers cannot be updated safely. - Obtain the exact block with `wfctl init <kind> --print-instructions guide`. - -## Ownership - -`.workflow/state.json` records the hash of every installed owned file. Update an owned file only when the on-disk hash still matches the prior installed hash. Equal content is safe to adopt. Any other pre-existing content is a conflict. -When a later release removes an owned file, delete it only if its hash still -matches the recorded installed hash. Back it up first. A locally modified -obsolete file remains an explicit conflict. - -`.workflow/repositories.json` is a dynamic, Git-tracked project source -registry, not a generated asset. It stores repository identity without local -paths. `.workflow/current/repositories.json` is ignored and stores any number -of known local worktrees per repository plus one explicit active selection -used only by default reconstruction. Leaf initialization adds a known worktree -but never changes an existing selection. diff --git a/.claude/skills/shape-project-direction/SKILL.md b/.claude/skills/shape-project-direction/SKILL.md deleted file mode 100644 index 0280caa..0000000 --- a/.claude/skills/shape-project-direction/SKILL.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -name: shape-project-direction -description: Run deliberate Wayfinder for a consequential project initiative whose destination is visible but route, product meaning, or architecture remains too foggy for one honest specification or agent session. Use only when the maintainer explicitly requests Wayfinder or accepts the router's recommendation. Do not use for ordinary brainstorming, a bounded large feature, current-project explanation, or product-code implementation. ---- - -# Shape Project Direction - -Wayfinder finds a route; it does not build the destination. It stores one -low-resolution map and bounded question issues inside the same central bundle -that later becomes the delivery specification. - -Read [the Wayfinder contract](references/direction-shaping-contract.md) before -charting or resuming. - -## Chart the map - -1. Reuse the initiative's active bundle or start one with `wfctl work start - <slug> --title "<destination>" --mode wayfinder`. Bind only already-known - evidence leaves; unknown future implementation repositories are not guessed. -2. Run `wfctl work context <id> --stage wayfind` and `wfctl work status <id>`. - Do not edit product source during this phase. -3. Run `wfctl work repositories <id>` and read what every bound repository - declares about itself — the instructions its maintainer wrote in its own - agent file, and the skills installed only there. A map charted from the - centre is charted without them otherwise, and they are exactly the rules - that decide whether a route is possible in that repository. Invoke - `align-project-knowledge` in the same pass: fog is only fog until you check - whether the project already answered it. `wfctl work map finish` refuses - until every bound repository is accounted for. -4. Name the destination first: what a finished map will make specifiable, for - whom, and the boundary it must not cross. -5. Explore breadth-first. Put a precise answerable question into a Wayfinder - issue. Put only still-unphraseable in-scope uncertainty into `map.md` fog. - Put ruled-out work under out of scope. -6. Create currently visible blockers before their dependants with `wfctl work - issue create --phase wayfinding --type - research|prototype|grilling|task`. Use `wfctl work issue block|unblock` when - later evidence changes an edge. Stop charting; do not resolve a normal issue - in the same session. - -If breadth-first exploration reveals no meaningful fog and the whole route fits -one session, stop and recommend ordinary `specify-project-change` instead. - -## Work one frontier question - -Run `wfctl work map status <id>`. Without a named issue, choose the first -frontier item. Read the map, selected issue, parent change, blockers, and -referenced artifacts completely; record current hash receipts. Claim before -work: - -```sh -wfctl work issue claim <id> <issue-id> --actor "agent:<identity>" -``` - -- `research` discovers an external or project fact without supplying product - authority; -- `prototype` creates a cheap artifact for a real human to react to; -- `grilling` resolves one product/domain decision with the maintainer, one - evidence-backed question at a time; -- `task` performs a prerequisite that makes a later decision possible. - -Ask one focused question at a time. Persist the answer before selecting another. - -Never answer the human side of a human-in-the-loop issue yourself. Complete at -most one non-research issue per session. Record the full answer and evidence in -the issue, then run `wfctl work issue complete`. The CLI adds only a named gist -to the map. Update newly visible issues and remove the corresponding fog so one -fact never lives in two competing places. - -## Hand off to specification - -The route is clear only when every Wayfinder issue is completed or explicitly -dropped, no in-scope fog remains, and the next bounded change can be specified -without guessing. Invoke `specify-project-change`: it must read all resolved -issues, synthesize stable acceptance criteria into `change.md`, obtain review, -and run `wfctl work map finish`. - -Do not jump from a map directly into implementation. The retained `map.md` is -decision lineage, while `change.md` becomes the current delivery contract. diff --git a/.claude/skills/shape-project-direction/agents/openai.yaml b/.claude/skills/shape-project-direction/agents/openai.yaml deleted file mode 100644 index 26653bd..0000000 --- a/.claude/skills/shape-project-direction/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Shape Project Direction" - short_description: "Resolve a large uncertain project direction" - default_prompt: "Use this skill to turn a broad, uncertain product or architecture initiative into a durable decision frontier before implementation." -policy: - allow_implicit_invocation: false diff --git a/.claude/skills/shape-project-direction/references/direction-shaping-contract.md b/.claude/skills/shape-project-direction/references/direction-shaping-contract.md deleted file mode 100644 index 1117873..0000000 --- a/.claude/skills/shape-project-direction/references/direction-shaping-contract.md +++ /dev/null @@ -1,59 +0,0 @@ -# Wayfinder contract - -## Entry test - -Use Wayfinder only when all are true: - -- the initiative can materially change product meaning, architecture, - ownership, or several Areas; -- the destination can be named, but multiple dependent choices still hide the - route; -- one honest specification cannot yet fit in a single well-reasoned session; -- the maintainer explicitly selected or accepted this mode. - -A large but bounded feature, migration, audit, or refactor belongs to ordinary -significant work. Brainstorming remains conversational until durable shaping is -actually wanted. - -## Map roles - -`map.md` is an index, not the store of every answer: - -- `destination` fixes what the route is finding and therefore its scope; -- `notes` holds standing domain and process context; -- `resolved` points by issue name to full issue resolutions; -- `fog` holds in-scope uncertainty that cannot yet be phrased precisely; -- `out_of_scope` holds work beyond the destination that never graduates. - -A precise unanswered question is an issue, even when blocked. A vague suspected -question remains fog. When resolution makes fog precise, create the new issue -and remove that fog item. - -## Issue roles - -- **Research (agent-driven):** establishes a fact from primary or project - sources. It cannot establish product authority. -- **Prototype (human-in-the-loop):** creates a cheap disposable artifact to - make a design question concrete. -- **Grilling (human-in-the-loop):** resolves a product, domain, or architecture - choice one focused question at a time. -- **Task (agent- or human-driven):** performs a prerequisite that exposes facts - needed by later decisions; it does not deliver the destination. - -The frontier contains ready, unclaimed issues whose blockers are completed. -Claim before work. Resolve one non-research issue per session. The full answer -and evidence live in that issue; the map stores only a gist and pointer. - -## Exit test - -Wayfinder is ready for specification only when: - -- destination and out-of-scope boundaries are explicit; -- every issue is completed or deliberately dropped with a reason; -- no in-scope fog remains; -- accepted language and decisions are distinguishable from proposals; -- the next bounded change can be specified without guessing. - -`specify-project-change` then reads the complete map bundle, synthesizes -`change.md`, and finishes the map into `full` or `slice`. It preserves `map.md` -as lineage and never bypasses specification into implementation. diff --git a/.claude/skills/specify-project-change/SKILL.md b/.claude/skills/specify-project-change/SKILL.md deleted file mode 100644 index 2f250c6..0000000 --- a/.claude/skills/specify-project-change/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: specify-project-change -description: Synthesize an already-discussed bounded change, or a resolved Wayfinder map, into the one central project specification. Use when the maintainer explicitly asks to write or refresh the spec, when a direction map is ready to become delivery work, or before splitting approved work into issues. Do not use to discover a huge unresolved direction, implement code, or create a second specification outside the active knowledge bundle. ---- - -# Specify Project Change - -Turn the context already earned through discussion, curated knowledge, and -source inspection into one buildable contract. Do not restart an interview or -discard decisions merely because a fresh template would be easier. - -Read [the specification contract](references/specification-contract.md) before -editing the bundle. - -## Bind and read - -1. Resolve the active change ID. If no significant-work bundle exists, invoke - `manage-project-work` to classify and start it first. -2. Run `wfctl work context <id> --stage shape`. For a resolved Wayfinder map, - run `--stage review` so every map issue and artifact is enumerated. -3. Read every required file completely, including content below long tables or - headings. Run `wfctl work review file <id> <path>` only after the whole file - has been read and reconciled. -4. Run `wfctl work status <id>`. Treat `Spec` as the only editable - specification and every `Code root` as an exact evidence workspace, never - as a path inferred from repository name. - -## Synthesize, do not invent - -Use the current discussion without asking the maintainer to repeat it. Reconcile -it with relevant curated knowledge and verified source reality. If code informs -the contract, invoke `analyze-with-graphify` in every relevant bound root, then -open the actual source locations. Invoke `align-project-knowledge` before -settling product or architecture meaning. - -Work spanning more than one repository is shaped here, at the centre, because -only the centre sees them all at once. What the centre does not see is what each -repository declares about itself: the instructions its maintainer wrote in its -own agent file, and the skills installed only there. Those are specific and -binding — one repository opens with a plan file to read first, another calls its -navigation rule BINDING — and a session that never entered the checkout has no -way to know they exist. - -```sh -wfctl work repositories <id> -``` - -This prints them without leaving the centre. Read every one, then account for -each bound repository before asking for approval: - -```sh -wfctl work repositories <id> --read <repository> --note "<what its rules require of this work>" -wfctl work repositories <id> --untouched <repository> --reason "<why this work does not reach it>" -``` - -The hash of its instructions and the list of its own skills are taken from the -checkout, not from you, so the receipt binds to what was there; if the -repository changes its rules afterwards the receipt is reported stale rather -than quietly wrong. Framing approval and `wfctl work map finish` both refuse -until every bound repository is one or the other. Saying nothing is not a third -option, and neither is a note that only says the file was opened. - -Update `change.md` with: - -- the problem and intended observable outcome; -- actors, current behavior, constraints, and explicit exclusions; -- approved product and engineering decisions with rationale; -- stable acceptance entries `AC-01`, `AC-02`, ... in frontmatter; -- the highest practical test seams and what behavior each seam proves; -- unresolved authority or facts, without guessing them away; -- the current ledger and structured resumable checkpoint. - -Acceptance criteria describe observable outcomes and boundary behavior, not a -file-by-file implementation plan. Preserve an ID when wording improves without -changing meaning. Retire or supersede changed meaning explicitly. - -Ask at most one blocking question at a time. Include verified facts, the -decision it unlocks, viable choices, and a recommendation. Persist the answer -before continuing. After each material edit or maintainer answer, refresh the -bundle checkpoint with `wfctl work checkpoint <id>`; run it last so its hash -binds the current `change.md` rather than an earlier draft. - -## Review and continue - -Present a compact framing packet — maintainer-facing, so the reader test in -`maintainer-review` governs every sentence: outcome, scope, exclusions, decisions, -acceptance IDs, test seams, risks, and unresolved work. Record only explicit -maintainer approval, and record it through the approval command rather than by -editing the receipt: - -```sh -wfctl work approve <id> --stage framing \ - --by human:<maintainer-id> \ - --note "<what was approved>" -``` - -Render the framing with `wfctl work ask <id>` and record their reply with -`--attested "<their words>" --session "<where>"`. A typed confirmation and a -`--token` matching `WFCTL_APPROVAL_TOKEN` remain available and are theirs to ask -for; do not send them to a second terminal by default. A hand-written -`maintainer_review.framing` receipt fails verification. Approving rewrites -`change.md`, so re-read it, refresh its review receipt, and refresh the -checkpoint afterwards. - -For Wayfinder, read every resolved issue in full, collapse its linked detail -into the specification, clear all legitimate fog, review every current bundle -file, then run: - -```sh -wfctl work map finish <id> --mode full|slice -``` - -The map remains as history; it is not copied into a parallel strategy file. -After approval, invoke `split-project-change` when the work needs multiple -fresh sessions. A small bounded change may remain entirely in `change.md`. diff --git a/.claude/skills/specify-project-change/agents/openai.yaml b/.claude/skills/specify-project-change/agents/openai.yaml deleted file mode 100644 index 8a5543a..0000000 --- a/.claude/skills/specify-project-change/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Specify Project Change" - short_description: "Synthesize one central change specification" - default_prompt: "Use $specify-project-change to synthesize the current discussion, project knowledge, and verified source context into the active central specification and refresh its checkpoint." -policy: - allow_implicit_invocation: false diff --git a/.claude/skills/specify-project-change/references/specification-contract.md b/.claude/skills/specify-project-change/references/specification-contract.md deleted file mode 100644 index 0bbdb26..0000000 --- a/.claude/skills/specify-project-change/references/specification-contract.md +++ /dev/null @@ -1,29 +0,0 @@ -# Specification contract - -The specification states what must become true and why. It is not a transcript, -an issue list, or an implementation diary. - -## Required synthesis - -- Explain the problem from the affected actor's perspective. -- Explain the outcome from that actor's perspective. -- Preserve the project's accepted domain language. -- Separate product behavior from engineering decisions without losing the link. -- Prefer existing high-level test seams; propose a new seam only when current - interfaces cannot prove the behavior safely. -- Record out-of-scope boundaries and known risks explicitly. -- Make every acceptance criterion observable, independently referencable, and - falsifiable. - -Do not include volatile source paths or large code snippets as the contract. -Exact source evidence belongs in progress and verification. A concise schema, -state machine, or type shape from a prototype may be retained when prose would -lose a settled decision; identify it as prototype-derived. - -## Wayfinder collapse - -The map is an index. Each completed issue owns the detailed answer. Synthesis -therefore requires reading all resolved issues, not expanding the one-line map -gists into guesses. Keep the map as lineage, carry accepted conclusions into -`change.md`, keep deferred questions visible, and do not create delivery issues -until this collapse is reviewed. diff --git a/.claude/skills/split-project-change/SKILL.md b/.claude/skills/split-project-change/SKILL.md deleted file mode 100644 index b8413ae..0000000 --- a/.claude/skills/split-project-change/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: split-project-change -description: Split one approved central change specification into dependency-aware tracer-bullet issues inside the same knowledge bundle. Use when the maintainer explicitly asks to create the execution breakdown, when delivery spans several safe agent sessions, or when parallel work needs an explicit frontier. Do not use before framing approval, for an unresolved Wayfinder map, or to create an external or leaf-local competing tracker. ---- - -# Split Project Change - -Create bounded execution units without fragmenting the specification or losing -acceptance coverage. - -Read [the issue-design contract](references/issue-design-contract.md) before -proposing the graph. - -## Read the approved contract - -1. Run `wfctl work context <id> --stage shape` and `wfctl work status <id>`. -2. Read `change.md` completely and confirm that framing is approved, the mode - is `full` or `slice`, and stable acceptance IDs exist. -3. Reuse current source and knowledge understanding. If the proposed breakdown - depends on implementation facts not yet checked, invoke Graphify-first - analysis in the exact relevant code roots before publishing tickets. - -## Draft the graph - -Prefer narrow complete tracer bullets: one issue produces independently -reviewable behavior across every necessary layer and fits in one fresh session. -Do not split work into database/API/UI horizontal layers merely because the -repository does. - -For each proposed issue show the maintainer: - -- a human-readable title; -- the complete behavior or decision it delivers; -- stable acceptance IDs it contributes to; -- exact repository identities it may touch; -- genuine blocking issues; -- whether it is small enough for one fresh context. - -Use expand-migrate-contract for a wide mechanical refactor that cannot keep the -system valid as independent vertical slices. Keep each migration batch bounded -by blast radius and make final contraction depend on every migration. - -Ask whether the granularity and dependency edges are right. Publish only the -approved graph. - -## Publish centrally - -Create issues in dependency order so blocker IDs already exist: - -```sh -wfctl work issue create <change-id> <slug> \ - --title "<title>" \ - --phase delivery \ - --type delivery \ - --satisfies AC-01 \ - --repository <repository-id> \ - --blocked-by ISSUE-001 -``` - -Repeat options as needed. Each issue receives its own ready checkpoint; never -create a second issue or progress file in a leaf. Run `wfctl work issue list -<change-id>` and confirm every acceptance ID is covered, the graph is acyclic, -and the frontier matches the intended concurrency. Refresh the parent -checkpoint last with the first executable frontier action. - -Do not implement during this skill. Hand a frontier issue to -`implement-work-item` in a fresh context. diff --git a/.claude/skills/split-project-change/agents/openai.yaml b/.claude/skills/split-project-change/agents/openai.yaml deleted file mode 100644 index c6cb2a7..0000000 --- a/.claude/skills/split-project-change/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Split Project Change" - short_description: "Split a change into checkpointed issues" - default_prompt: "Use $split-project-change to split the approved central specification into dependency-aware issues with one ready checkpoint each." -policy: - allow_implicit_invocation: false diff --git a/.claude/skills/split-project-change/references/issue-design-contract.md b/.claude/skills/split-project-change/references/issue-design-contract.md deleted file mode 100644 index 2cb0c00..0000000 --- a/.claude/skills/split-project-change/references/issue-design-contract.md +++ /dev/null @@ -1,27 +0,0 @@ -# Issue design contract - -An issue is an executable unit, not a copy of the parent spec. - -## Good issue - -- Delivers one complete behavior or resolves one explicit work prerequisite. -- Fits in one fresh agent session when possible. -- Names the acceptance IDs it contributes to. -- Declares only dependencies that genuinely prevent starting. -- Identifies repository scope without prescribing stale file paths. -- Can be verified independently at a public or project-approved seam. -- Leaves one precise, hash-bound checkpoint after every material turn. - -## Bad splits - -- one ticket per technical layer; -- one giant issue that silently relies on conversation memory; -- blockers used merely as ordering preferences; -- acceptance text duplicated and allowed to drift; -- source paths or snippets treated as permanent requirements; -- a local leaf checklist competing with the central bundle. - -Prefactoring may be its own prerequisite issue when it makes the later behavior -safe and easy. It must still define an observable structural outcome and fresh -checks. Wide refactors use expand-migrate-contract rather than pretending each -layer is independently shippable. diff --git a/.claude/skills/state-management/SKILL.md b/.claude/skills/state-management/SKILL.md deleted file mode 100644 index cf5bb12..0000000 --- a/.claude/skills/state-management/SKILL.md +++ /dev/null @@ -1,263 +0,0 @@ ---- -name: state-management -description: Use whenever work creates, changes, reviews, debugs, or consumes shared client state, especially Zustand stores, selectors, actions, store namespaces, feature or page stores, store factories and providers, persisted state, migrations, hydration guards, secure storage, authentication state, cross-store workflows, resets, or long-running client processes. Trigger when deciding whether state belongs in Zustand, TanStack Query, router params or search, React Hook Form, React state, or context, even if the request does not name a state-management library. ---- - -# State Management - -Use Zustand for **shared client-owned state**, not as the default owner of every -value. Choose the owner and lifetime before choosing store syntax. - -Adapt imports and storage implementations to the repository. Preserve these -ownership and security boundaries even when the local Zustand idiom differs. - -## Decide The Owner First - -Keep each kind of state with its real authority: - -- Refetchable external or backend data belongs to the query layer. Do not copy - query results into Zustand through `useEffect`. -- Resource identity belongs in path params. Filters, sorting, pagination, tabs, - and other navigable state belong in search params. -- Form fields, validation, dirty state, and submission state belong to the form. -- State used by one component belongs in React state. -- State shared only by one composable subtree usually belongs in context. -- Shared client state, imperative client state, state that crosses unrelated - React branches, and client processes that outlive one component may belong in - Zustand. - -Persistence is a separate decision. A value does not belong in Zustand merely -because it must survive a restart, and a Zustand value need not be persisted. - -Read `references/ownership-and-scope.md` when choosing between these owners or -between a singleton and a scoped store. - -## Build Bounded Capability Stores - -Create a store around one cohesive client capability. Do not merge unrelated -state merely to reduce the number of stores, and do not split one atomic -capability into a store per field. - -A module-level `create(...)` is a singleton regardless of where its file lives. -Use it only when the capability truly has one application-wide or -feature-wide lifetime. Use a `createStore` factory with context when state: - -- belongs to one mounted page, editor, or component instance; -- is initialized from props; -- must reset on unmount; -- may have multiple simultaneous instances; -- must be isolated per SSR request, test, or story. - -Locate the store at the narrowest boundary containing all of its consumers. -Follow **file-structure** for the concrete directory shape. - -## Choose The Store Definition Deliberately - -Prefer `create(combine(initialState, actions))` for a small store whose inferred -shape stays obvious. Use an explicit store type when it clarifies a factory, -public contract, complex async lifecycle, middleware stack, or process manager. -Never force `combine` merely to avoid writing a useful type. - -Keep ordinary state transitions beside their state. An action defined by one -store may read and mutate that store only; it must not import another store. -Keep a reusable initial state and provide an explicit reset when the capability -has a reset lifecycle. - -Do not store derived values that can be computed cheaply from current state. -Compute them in a selector or render instead of synchronizing them through an -effect. - -See `references/store-patterns.md` for simple, explicit, scoped, selector, and -reset examples. - -## Expose A Discoverable Namespace - -Expose the store through one capability namespace: - -```ts -export const preferencesStore = { - useStore: usePreferencesStore, -} -``` - -Selecting an ordinary field directly is the default: - -```ts -const theme = preferencesStore.useStore((state) => state.theme) -``` - -Do not generate one wrapper hook per field. Add a named hook only when it -expresses a meaningful derived concept, composes several stores, centralizes -equality behavior, or deliberately hides an unstable internal representation. - -A namespace may also expose feature-level commands and hooks. Treat the -namespace as the public facade of the capability, not as a claim that every -member is a literal Zustand action. - -## Subscribe Narrowly - -- Select the narrowest field or derived primitive the consumer needs. -- Use the repository's shallow-equality helper, such as `useShallow`, when a - selector returns an object, array, or other shallow-comparable collection. -- Avoid `useStore()` without a selector; it subscribes to every change. -- Never mutate objects, arrays, `Map`, or `Set` in place. Return a new reference. -- Inside React, subscribe through the hook. Outside React, use `getState()` or a - public command. -- Use direct `setState()` primarily for tests, stories, framework integration, - and controlled setup. Production mutations go through owned actions or - commands. - -## Keep Orchestration Outside Store Actions - -Feature-level commands and hooks may coordinate several stores, the query -client, storage capabilities, and transports when the workflow clearly belongs -to that feature. Logout is a typical example. - -Prefer a plain command for orchestration that does not require React, then wrap -it in a hook or mutation when the UI needs pending, error, or success state. -Keep navigation, toasts, and other caller-specific UX at the caller unless they -are an invariant of the workflow. - -Derived hooks may subscribe to multiple stores. This is composition, not a -store-to-store dependency. - -Read `references/processes-and-coordination.md` for command, logout, and -cross-store examples. - -## Persist Only An Explicit Contract - -Do not add `persist` by habit. For every persisted store: - -- give the storage key one stable owner; -- whitelist the minimal persisted shape with `partialize`; -- exclude actions, hydration state, in-flight state, caches, runtime handles, - access tokens, and other transient values; -- establish a version before the persisted contract ships; -- validate persisted `unknown` input rather than trusting a cast; -- define migrations for breaking shape changes; -- define a custom merge when nested defaults require more than Zustand's - shallow merge; -- use the repository's storage adapter rather than reading or parsing - Zustand's serialized value elsewhere. - -Choose failure behavior by data class. A disposable cache may reset explicitly. -A decrypt, corruption, or migration failure for credentials, user-created -offline data, or another critical store must surface as an error and must not -silently become “no saved state.” - -Do not rely on `await set(...)` as a portable persistence-completion contract. -When the next operation requires durable completion, put that guarantee behind -a dedicated, tested persistence capability. - -## Model Hydration As A Prerequisite - -Synchronous and asynchronous adapters hydrate differently. Do not add a -hydration gate when no consumer depends on it, and do not assume all persisted -stores hydrate asynchronously. - -For critical asynchronous state, model at least: - -```ts -type HydrationState = - | { status: "hydrating"; error: null } - | { status: "ready"; error: null } - | { status: "error"; error: unknown } -``` - -Use ordered guards when application regions depend on persisted state: - -```tsx -<SecureStoresGuard> - <SessionGuard> - <AccessGuard> - <CriticalDataGuard>{children}</CriticalDataGuard> - </AccessGuard> - </SessionGuard> -</SecureStoresGuard> -``` - -Each guard owns one prerequisite and either renders its loading, error, -blocked, or retry state or passes `children`. Outer guards establish the -invariants assumed by inner guards. Never interpret pre-hydration defaults as -logout, missing credentials, or permission denial. - -Read `references/persistence-and-security.md` before changing persistence, -hydration, authentication storage, encryption, or guards. - -## Keep Secrets Out Of Zustand When Possible - -Secure storage protects data at rest; it does not protect plaintext after a -secret is returned to renderer JavaScript. - -- Keep reactive session metadata in Zustand: status, account identity, - expiration, lock state, and hydration state. -- Keep a short-lived access token in private auth-client memory only when the - renderer must perform authenticated requests. It rarely needs reactivity. -- Keep refresh tokens in an OS-backed native vault or an HttpOnly backend - session where the platform permits. -- Keep private keys non-exportable behind a native or platform capability. - Expose operations such as `sign`, `decrypt`, or `refreshSession`, not - `getSecret` or `getPrivateKey`. -- Never call an adapter `secure` when it is only `localStorage`, ordinary - filesystem storage, or another unprotected backend. -- A storage read returns `null` only when data is genuinely absent. Decryption - or key-access failure must remain an error. - -If architecture forces a raw secret into JavaScript, keep it out of global -reactive state, persistence, devtools, logs, and long-lived strings. Limit it to -the narrowest operation and treat buffer clearing as best effort, not a -security guarantee. - -Follow **native-integration** for capability and RPC boundaries and -**logging** for keeping secrets out of diagnostic records and -**error-handling** for safe incident context. - -## Allow Explicit Process Stores - -A Zustand store may manage a long-running client process when the process: - -- outlives one component or route; -- needs imperative start, cancel, retry, resume, or reattach operations; -- publishes progress to unrelated consumers; -- has a client-owned lifecycle rather than query-owned server state. - -Model explicit status transitions and reject duplicate or stale work. Keep -`AbortController`, streams, sockets, and other runtime handles outside -observable state when consumers do not need them. Never persist those handles; -persist only a deliberate resume record through its owning storage capability. - -When transitions become complex, use an explicit reducer or state machine -inside the capability rather than hiding an implicit state machine in scattered -booleans. - -Read `references/processes-and-coordination.md` before creating a process store. - -## Verify The Boundary - -Before finishing state-management work: - -1. Confirm each value still has one owner. -2. Confirm URL, form, and query state were not duplicated into Zustand. -3. Confirm singleton versus scoped lifetime is intentional. -4. Inspect every React subscription for unnecessary breadth. -5. Exercise reset, logout, remount, and concurrent-operation behavior. -6. For persistence, test migration, corrupted input, missing data, hydration - failure, retry, and durable-write ordering where relevant. -7. For secrets, verify what crosses into renderer memory and whether a narrower - capability can keep it out. - -## Related Skills - -- **api-integration** owns external operations, server state, query identities, - mutations, invalidation, and auth transport behavior. -- **routing** owns path and search state with navigation meaning. -- **forms** owns form values, validation, submission, and field errors. -- **components** owns React-local state, context, composition, and effect - avoidance. -- **file-structure** owns the concrete placement of store files and private - implementation. -- **native-integration** owns OS-backed storage, cryptography, privileged - capabilities, and renderer bridges. -- **logging** owns diagnostic records and the rule that secrets never enter - them. -- **error-handling** owns incident-reporting policy and safe reporting context. diff --git a/.claude/skills/state-management/references/ownership-and-scope.md b/.claude/skills/state-management/references/ownership-and-scope.md deleted file mode 100644 index b56b011..0000000 --- a/.claude/skills/state-management/references/ownership-and-scope.md +++ /dev/null @@ -1,165 +0,0 @@ -# State Ownership And Scope - -Use this reference when deciding whether state belongs in Zustand and whether a -store should be a module singleton or a scoped instance. - -## Contents - -- [Ownership Matrix](#ownership-matrix) -- [Do Not Duplicate Query State](#do-not-duplicate-query-state) -- [Singleton Stores](#singleton-stores) -- [Scoped Store Factories](#scoped-store-factories) -- [Context Without Zustand](#context-without-zustand) - -## Ownership Matrix - -| State | Default owner | -| --- | --- | -| Refetchable backend or external data | Query layer | -| Resource identity | Route path params | -| Filters, sorting, pagination, tabs, view options | Route search params | -| Form values, validation, dirty and submit state | Form library | -| One component | React state or reducer | -| One composable subtree | React context | -| Shared client-owned state | Zustand | -| Long-running client process | Process store or dedicated client capability | -| Restart-surviving subset | Explicit persistence boundary | - -These are ownership defaults, not library prohibitions. Change one only when the -value has a different real authority, and document that authority. - -## Do Not Duplicate Query State - -Do not copy query data into a store: - -```ts -const query = useQuery(projectQueryOptions(projectId)) -const setProject = projectStore.useStore((state) => state.setProject) - -useEffect(() => { - if (query.data) setProject(query.data) -}, [query.data, setProject]) -``` - -Read the query directly. Keep only genuine client state in Zustand: - -```ts -const query = useQuery(projectQueryOptions(projectId)) -const selectedPanel = projectWorkspaceStore.useStore( - (state) => state.selectedPanel, -) -``` - -An offline editor, local draft authority, or client process may intentionally -own a snapshot. Make that ownership explicit; do not create it merely to avoid -reading the query cache. - -## Singleton Stores - -A module-level store is appropriate for a true singleton: - -```ts -const usePreferencesStore = create( - combine( - { theme: "system" as ThemePreference }, - (set) => ({ - setTheme: (theme: ThemePreference) => set({ theme }), - }), - ), -) - -export const preferencesStore = { - useStore: usePreferencesStore, -} -``` - -Typical singleton capabilities include application preferences, one active -session, and one application-wide background queue. - -Colocating this file with a page narrows its ownership but does not change its -runtime lifetime. It remains a module singleton. - -## Scoped Store Factories - -Use a factory when each mounted owner needs an independent instance: - -```tsx -import { - createContext, - type PropsWithChildren, - useContext, - useState, -} from "react" -import { createStore, useStore } from "zustand" - -type ProjectWorkspaceStore = { - projectId: string - selectedNodeId: string | null - selectNode: (nodeId: string | null) => void -} - -const createProjectWorkspaceStore = (projectId: string) => - createStore<ProjectWorkspaceStore>()((set) => ({ - projectId, - selectedNodeId: null, - selectNode: (selectedNodeId) => set({ selectedNodeId }), - })) - -type ProjectWorkspaceStoreApi = ReturnType< - typeof createProjectWorkspaceStore -> - -const ProjectWorkspaceStoreContext = - createContext<ProjectWorkspaceStoreApi | null>(null) - -export function ProjectWorkspaceStoreProvider({ - projectId, - children, -}: PropsWithChildren<{ projectId: string }>) { - const [store] = useState(() => createProjectWorkspaceStore(projectId)) - - return ( - <ProjectWorkspaceStoreContext.Provider value={store}> - {children} - </ProjectWorkspaceStoreContext.Provider> - ) -} - -export function useProjectWorkspaceStore<T>( - selector: (state: ProjectWorkspaceStore) => T, -) { - const store = useContext(ProjectWorkspaceStoreContext) - - if (!store) { - throw new Error( - "useProjectWorkspaceStore must be used within ProjectWorkspaceStoreProvider", - ) - } - - return useStore(store, selector) -} -``` - -Create the store once for that provider instance. Do not recreate it on every -render. Key or remount the owner deliberately when its identity changes. - -Use the same pattern for SSR request isolation. Never share a mutable -module-level store between server requests. - -## Context Without Zustand - -Do not add Zustand merely because several compound components share state: - -```tsx -const SelectionContext = createContext<SelectionContextValue | null>(null) -``` - -Prefer plain context when: - -- all consumers live under one obvious provider; -- no imperative access outside React is needed; -- update frequency and provider scope are controlled; -- the state should disappear with that subtree. - -Use a scoped Zustand store when selector-based subscriptions, imperative access, -or a substantial state transition model materially improves that subtree. diff --git a/.claude/skills/state-management/references/persistence-and-security.md b/.claude/skills/state-management/references/persistence-and-security.md deleted file mode 100644 index a31ca44..0000000 --- a/.claude/skills/state-management/references/persistence-and-security.md +++ /dev/null @@ -1,391 +0,0 @@ -# Persistence, Hydration, Guards, And Secrets - -Use this reference before implementing or reviewing persisted state, -authentication storage, secure adapters, migrations, hydration, or application -guards. - -## Contents - -- [Persist A Stable Subset](#persist-a-stable-subset) -- [Validate, Migrate, And Merge](#validate-migrate-and-merge) -- [Hydration State](#hydration-state) -- [Guard Composition](#guard-composition) -- [Storage Failure Semantics](#storage-failure-semantics) -- [Secret Placement](#secret-placement) -- [Platform Options](#platform-options) - -## Persist A Stable Subset - -Persist only state that must survive a restart: - -```ts -import { create } from "zustand" -import { - combine, - createJSONStorage, - persist, -} from "zustand/middleware" - -const usePreferencesStore = create( - persist( - combine( - { - theme: "system" as ThemePreference, - density: "comfortable" as DensityPreference, - previewTheme: null as ThemePreference | null, - }, - (set) => ({ - setTheme: (theme: ThemePreference) => set({ theme }), - }), - ), - { - name: "preferences", - version: 1, - storage: createJSONStorage(() => applicationStorage), - partialize: (state) => ({ - theme: state.theme, - density: state.density, - }), - }, - ), -) -``` - -`previewTheme`, actions, hydration state, request state, and runtime handles do -not persist. - -Do not read or parse the serialized Zustand value from another module. If -pre-React bootstrap needs a persisted preference, expose that through the -storage capability that owns the contract. - -## Validate, Migrate, And Merge - -Persisted data is untrusted `unknown` input. Validate it with the repository's -existing schema mechanism: - -```ts -const persistedPreferencesSchema = z.object({ - theme: z.enum(["system", "light", "dark"]), - density: z.enum(["compact", "comfortable"]), -}) - -type PersistedPreferences = z.infer< - typeof persistedPreferencesSchema -> -``` - -Use an explicit persisted return type: - -```ts -partialize: (state): PersistedPreferences => ({ - theme: state.theme, - density: state.density, -}) -``` - -Migration receives `unknown`: - -```ts -migrate: (persistedState, version) => { - if (version === 0) { - const legacy = legacyPreferencesSchema.parse(persistedState) - - return { - theme: legacy.colorMode, - density: "comfortable", - } - } - - return persistedPreferencesSchema.parse(persistedState) -} -``` - -Zustand's default merge is shallow. Merge nested defaults deliberately: - -```ts -merge: (persistedState, currentState) => { - const persisted = persistedWorkspaceSchema.parse(persistedState) - - return { - ...currentState, - workspace: { - ...currentState.workspace, - ...persisted.workspace, - }, - } -} -``` - -Do not add a migration function that silently converts every invalid critical -value into an empty default. Decide whether the data is disposable or whether -failure must block and offer recovery. - -## Hydration State - -Synchronous storage may hydrate during store creation. Asynchronous storage -hydrates later. Add a gate only when consumers must not observe defaults before -hydration. - -For simple noncritical state, a boolean may be sufficient: - -```ts -type HydrationState = { - hasHydrated: boolean -} -``` - -For critical state, preserve loading and error: - -```ts -type HydrationStatus = - | { status: "hydrating"; error: null } - | { status: "ready"; error: null } - | { status: "error"; error: unknown } -``` - -Several secure stores may share a hydration registry: - -```ts -type SecureStoresHydration = { - session: HydrationStatus - accounts: HydrationStatus -} -``` - -Set `skipHydration: true` when native preparation, an unlock step, SSR, or -another prerequisite must happen before the first read: - -```ts -persist(stateCreator, { - name: "session", - storage: createJSONStorage(() => secureSessionStorage), - skipHydration: true, -}) -``` - -Start hydration explicitly when the adapter requires preparation: - -```ts -export async function hydrateSessionStore() { - hydrationStore.useStore.getState().begin("session") - - try { - await sessionVault.prepare() - await sessionStore.useStore.persist.rehydrate() - hydrationStore.useStore.getState().succeed("session") - } catch (error) { - hydrationStore.useStore.getState().fail("session", error) - throw error - } -} -``` - -Keep the original error. Do not invent a normalized error representation solely -for hydration. - -Do not assume `await set(...)` is a portable durable-write contract. If a -workflow must wait until encrypted persistence completes before locking, -navigating, or making another request, expose and test that guarantee through -the persistence capability. - -## Guard Composition - -Compose one prerequisite per guard: - -```tsx -function Application({ children }: PropsWithChildren) { - return ( - <SecureStoresGuard> - <SessionGuard> - <AccessGuard> - <CriticalDataGuard> - <ApplicationLayout>{children}</ApplicationLayout> - </CriticalDataGuard> - </AccessGuard> - </SessionGuard> - </SecureStoresGuard> - ) -} -``` - -The order is the contract: - -1. Secure persistence is readable. -2. Session presence is known. -3. Authorization is settled. -4. Critical application data is available. -5. The application layout and its consumers may mount. - -Each guard selects only its prerequisite and owns its fallback: - -```tsx -function SecureStoresGuard({ children }: PropsWithChildren) { - const hydration = hydrationStore.useStore((state) => state.session) - - if (hydration.status === "hydrating") { - return <FullScreenSpinner /> - } - - if (hydration.status === "error") { - return ( - <SecureStorageError - error={hydration.error} - onRetry={hydrateSessionStore} - /> - ) - } - - return children -} -``` - -Do not let a guard infer logout, denial, or missing data from a value whose -prerequisite has not passed. A hydration error must not automatically clear the -session. - -Hydration may start in the application entrypoint so no synchronization effect -is needed: - -```ts -void hydrateSecureStores().catch(() => { - // The hydration registry retains the original error for its guard. -}) - -createRoot(rootElement).render(<Application />) -``` - -An established query layer is also acceptable for async bootstrap when its -pending, retry, and error lifecycle fits the operation. - -## Storage Failure Semantics - -Return `null` only for genuine absence: - -```ts -async function getItem(name: string) { - const encryptedValue = backingStorage.getItem(name) - - if (encryptedValue === null) return null - - return nativeSecurity.decrypt(encryptedValue) -} -``` - -Do not swallow decryption failure: - -```ts -// Wrong: this changes "unreadable" into "missing". -async function getItem(name: string) { - try { - return await decrypt(backingStorage.getItem(name)) - } catch { - return null - } -} -``` - -The false “missing” result can hydrate empty defaults and later overwrite -recoverable encrypted data. - -## Secret Placement - -Secure storage protects data at rest. Once plaintext is returned to renderer -JavaScript, it may be reachable through application code, devtools, heap -snapshots, injected code, logs, or accidental persistence. - -Prefer this separation: - -```text -Zustand - session status - account identity - expiration - lock and hydration state - -Private auth-client memory - short-lived access token, only if renderer requests require it - -Native or backend vault - refresh token - private keys - long-lived credentials -``` - -An access token rarely needs reactivity. Prefer a private provider when the -renderer must hold it: - -```ts -let accessToken: string | null = null - -export const accessTokenProvider = { - get: () => accessToken, - set: (value: string | null) => { - accessToken = value - }, - clear: () => { - accessToken = null - }, -} -``` - -This reduces accidental exposure but does not protect against full renderer -compromise. - -Do not export raw private keys. Keep them non-exportable and expose operations: - -```ts -type SigningCapability = { - createKey: () => Promise<{ - keyId: string - publicKey: string - }> - sign: (input: { - keyId: string - payload: Uint8Array - }) => Promise<Uint8Array> - deleteKey: (keyId: string) => Promise<void> -} -``` - -Zustand may keep `keyId`, `publicKey`, and readiness state. The private key stays -behind the capability. - -When a raw key must temporarily enter JavaScript, keep it in the narrowest -lexical scope, prefer mutable byte buffers over immutable strings, never place -it in Zustand or persistence, and clear buffers as best effort. JavaScript -runtimes may retain copies, so do not claim guaranteed zeroization. - -## Platform Options - -Inspect the repository's threat model, existing native capabilities, and the -current official platform documentation before choosing an adapter. Security -semantics and fallbacks vary by operating system and runtime version. - -Choose the strongest boundary the platform supports: - -- Browser with backend: prefer a Backend-for-Frontend or HttpOnly, Secure, - SameSite session so tokens never enter application JavaScript. -- Browser without backend mediation: keep short-lived access tokens in memory; - never use `localStorage` or `sessionStorage` for credentials; require refresh - token rotation or sender constraint when refresh tokens are issued. -- Web cryptography: prefer non-extractable `CryptoKey` handles for local key - operations. They prevent raw export but cannot prevent compromised same-origin - code from invoking an allowed operation. -- Electron or another desktop shell: keep secrets and cryptographic operations - in the privileged native/main capability. Use OS Keychain, DPAPI, Secret - Service, or a framework wrapper such as Electron `safeStorage`; verify that - the selected Linux backend is not a plaintext fallback. -- Apple platforms: prefer Keychain and non-exportable key operations, using - Secure Enclave where the required algorithm and lifecycle permit it. -- Android: prefer Android Keystore and hardware-backed, non-exportable keys when - available. -- React Native or Expo: use the established Keychain/Keystore-backed secure - storage adapter for small tokens and secrets; prefer native non-exportable key - APIs over storing raw PEM keys. - -For larger encrypted data, store ciphertext in ordinary application storage and -keep only its data-encryption or wrapping key in the OS vault. - -Expose narrow native methods such as `sign`, `refreshSession`, and -`clearSession`. Never expose a generic `getSecret` or an unrestricted IPC -surface. diff --git a/.claude/skills/state-management/references/processes-and-coordination.md b/.claude/skills/state-management/references/processes-and-coordination.md deleted file mode 100644 index eec5867..0000000 --- a/.claude/skills/state-management/references/processes-and-coordination.md +++ /dev/null @@ -1,233 +0,0 @@ -# Process Stores And Coordination - -Use this reference for long-running client processes, cross-store workflows, -logout, and feature-level commands. - -## Contents - -- [Process Store](#process-store) -- [Runtime Handles](#runtime-handles) -- [Stale Work And Reattachment](#stale-work-and-reattachment) -- [Cross-Store Commands](#cross-store-commands) -- [Hook Adapters](#hook-adapters) - -## Process Store - -Use a process store when work must continue independently of one component: - -```ts -type ExportProcessState = { - status: "idle" | "running" | "completed" | "failed" - progress: number - error: unknown -} - -type ExportProcessActions = { - start: (input: ExportInput) => Promise<void> - cancel: () => void - reset: () => void -} - -type ExportProcessStore = ExportProcessState & ExportProcessActions - -const initialState: ExportProcessState = { - status: "idle", - progress: 0, - error: null, -} - -let activeController: AbortController | null = null - -const useExportProcessStore = create<ExportProcessStore>()((set, get) => ({ - ...initialState, - - start: async (input) => { - if (get().status === "running") return - - const controller = new AbortController() - activeController = controller - set({ status: "running", progress: 0, error: null }) - - try { - await exportClient.run(input, { - signal: controller.signal, - onProgress: (progress) => set({ progress }), - }) - - if (activeController !== controller || controller.signal.aborted) return - - set({ status: "completed", progress: 1 }) - } catch (error) { - if (activeController !== controller || controller.signal.aborted) return - - set({ status: "failed", error }) - } finally { - if (activeController === controller) activeController = null - } - }, - - cancel: () => { - activeController?.abort() - activeController = null - set(initialState) - }, - - reset: () => { - if (get().status === "running") return - set(initialState) - }, -})) - -export const exportProcessStore = { - useStore: useExportProcessStore, -} -``` - -The identity check prevents a late completion from an older operation from -overwriting a newer run. - -## Runtime Handles - -Keep runtime handles outside observable state unless consumers genuinely need -them: - -- `AbortController`; -- sockets and streams; -- timers; -- imperative clients; -- native resource handles; -- promises and async iterators. - -They are implementation details, are usually non-serializable, and must never -enter persisted state. - -If the process must survive a renderer restart, persist a deliberate resume -record through the process's storage capability: - -```ts -type ResumeRecord = { - runId: string - resourceId: string - startedAt: string -} -``` - -Do not persist the controller, stream, client, or accumulated implementation -object graph. - -## Stale Work And Reattachment - -Long-running stores must define: - -- what happens when `start` is called twice; -- how cancellation settles state; -- whether a finished result may update a newer run; -- whether restart means reattach, resume, reconcile, or fail; -- who owns any persisted resume record; -- when retry is allowed. - -Prefer explicit status or transition models over independent booleans: - -```ts -type ProcessStatus = - | { type: "idle" } - | { type: "running"; runId: string } - | { type: "resumable"; runId: string } - | { type: "completed" } - | { type: "failed"; error: unknown } -``` - -Use a reducer or state machine when valid transitions are no longer obvious -from a small store. - -## Cross-Store Commands - -A store action owns only its own state: - -```ts -const useSessionStore = create( - combine(sessionInitialState, (set) => ({ - clear: () => set(sessionInitialState), - })), -) -``` - -Do not import peer stores into that state creator. Coordinate them in a -feature-level command: - -```ts -export async function logout() { - await queryClient.cancelQueries() - queryClient.clear() - - sessionStore.useStore.getState().clear() - userStore.useStore.getState().clear() - notificationsStore.useStore.getState().clear() - - await sessionVault.clearSession() -} -``` - -The explicit list is intentional. Avoid a generic reset registry until several -real workflows prove that it improves ownership rather than hiding it. - -The command may live in the session capability and appear in its namespace: - -```ts -export const sessionStore = { - useStore: useSessionStore, - logout, -} -``` - -This is allowed because the namespace is a feature facade. `logout` is not -pretending to be an action inside the Zustand state creator. - -If several stores must always change atomically, reconsider their boundary. -They may be slices of one capability rather than independent stores. - -## Hook Adapters - -Wrap a plain command when React needs mutation lifecycle: - -```ts -function useLogoutMutation() { - return useMutation({ - mutationKey: ["session", "logout"], - mutationFn: logout, - }) -} - -export const sessionStore = { - useStore: useSessionStore, - logout, - useLogoutMutation, -} -``` - -Keep caller-specific navigation and feedback at the caller: - -```ts -const logoutMutation = sessionStore.useLogoutMutation() - -const handleLogout = async () => { - await logoutMutation.mutateAsync() - await router.invalidate() -} -``` - -A feature-level hook may compose multiple stores reactively: - -```ts -function useCanStartExport() { - const projectId = projectStore.useStore((state) => state.selectedId) - const processStatus = exportProcessStore.useStore( - (state) => state.status, - ) - - return projectId !== null && processStatus === "idle" -} -``` - -This is allowed. The state creators remain independent; the hook owns the -composition. diff --git a/.claude/skills/state-management/references/store-patterns.md b/.claude/skills/state-management/references/store-patterns.md deleted file mode 100644 index 15cc8f1..0000000 --- a/.claude/skills/state-management/references/store-patterns.md +++ /dev/null @@ -1,211 +0,0 @@ -# Store Patterns - -Use this reference for ordinary store definitions, namespaces, selectors, and -reset behavior. - -## Contents - -- [Simple Inferred Store](#simple-inferred-store) -- [Explicit Store Contract](#explicit-store-contract) -- [Namespace And Selectors](#namespace-and-selectors) -- [Immutable Updates](#immutable-updates) -- [Reset](#reset) - -## Simple Inferred Store - -Use `combine` when inference keeps a small store clearer: - -```ts -import { create } from "zustand" -import { combine } from "zustand/middleware" - -const initialState = { - selectedId: null as string | null, - expandedIds: [] as string[], -} - -const useNavigationStore = create( - combine(initialState, (set) => ({ - select: (selectedId: string | null) => set({ selectedId }), - expand: (id: string) => - set((state) => ({ - expandedIds: state.expandedIds.includes(id) - ? state.expandedIds - : [...state.expandedIds, id], - })), - reset: () => set({ ...initialState, expandedIds: [] }), - })), -) - -export const navigationStore = { - useStore: useNavigationStore, -} -``` - -Do not use state replacement with an inferred `combine` store. Replacing only -the initial-state shape can remove its actions. - -## Explicit Store Contract - -Use an explicit contract when it documents a factory, async lifecycle, public -package surface, or complex store better than inference: - -```ts -type UploadState = { - status: "idle" | "uploading" | "completed" | "failed" - progress: number - error: unknown -} - -type UploadActions = { - setProgress: (progress: number) => void - fail: (error: unknown) => void - complete: () => void - reset: () => void -} - -type UploadStore = UploadState & UploadActions - -const initialUploadState: UploadState = { - status: "idle", - progress: 0, - error: null, -} - -const useUploadStore = create<UploadStore>()((set) => ({ - ...initialUploadState, - setProgress: (progress) => set({ status: "uploading", progress }), - fail: (error) => set({ status: "failed", error }), - complete: () => set({ status: "completed", progress: 1 }), - reset: () => set(initialUploadState), -})) -``` - -Keep useful types beside the capability. Do not move them into a generic -`types.ts` file. - -## Namespace And Selectors - -Expose one discoverable namespace: - -```ts -const useStore = create(/* ... */) - -export const workspaceStore = { - useStore, -} -``` - -Select ordinary fields directly: - -```tsx -const selectedId = workspaceStore.useStore((state) => state.selectedId) -const select = workspaceStore.useStore((state) => state.select) -``` - -Select several fields with shallow equality: - -```tsx -const { status, progress } = uploadStore.useStore( - useShallow((state) => ({ - status: state.status, - progress: state.progress, - })), -) -``` - -Do not subscribe to the whole store: - -```tsx -// Avoid: rerenders for every store change. -const store = uploadStore.useStore() -``` - -Create a named hook when it owns meaningful logic: - -```ts -const useCanCancelUpload = () => - uploadStore.useStore( - (state) => state.status === "uploading" && state.progress < 1, - ) -``` - -A named hook may compose stores without creating a dependency between their -state creators: - -```ts -const useCanOpenWorkspace = () => { - const isAuthenticated = sessionStore.useStore( - (state) => state.status === "authenticated", - ) - const projectId = projectStore.useStore((state) => state.selectedId) - - return isAuthenticated && projectId !== null -} -``` - -Outside React, prefer a public command or use `getState()`: - -```ts -workspaceStore.useStore.getState().select(projectId) -``` - -## Immutable Updates - -Return new references: - -```ts -set((state) => ({ - items: [...state.items, item], -})) -``` - -Create new `Map` and `Set` instances: - -```ts -set((state) => ({ - selectedIds: new Set(state.selectedIds).add(id), -})) -``` - -Never mutate and return the existing reference: - -```ts -// Wrong: subscribers may not observe a change. -set((state) => { - state.selectedIds.add(id) - return { selectedIds: state.selectedIds } -}) -``` - -## Reset - -Model reset as an owned action: - -```ts -const createInitialState = (): NavigationState => ({ - selectedId: null, - expandedIds: new Set(), -}) - -const useNavigationStore = create<NavigationState & NavigationActions>()( - (set) => ({ - ...createInitialState(), - reset: () => set(createInitialState()), - }), -) -``` - -Use a factory when initial state contains mutable collections so reset never -reuses a previously mutated reference. - -Direct `setState()` is acceptable for tests and stories: - -```ts -beforeEach(() => { - useNavigationStore.setState(createInitialState()) -}) -``` - -Do not replace state with a state-only object because that removes actions. -Reset through the owned action when it represents the public test contract. diff --git a/.claude/skills/user-feedback/SKILL.md b/.claude/skills/user-feedback/SKILL.md deleted file mode 100644 index e353abd..0000000 --- a/.claude/skills/user-feedback/SKILL.md +++ /dev/null @@ -1,217 +0,0 @@ ---- -name: user-feedback -description: Use whenever work adds, changes, reviews, or debugs user-facing outcome feedback such as error, success, warning, or informational messages; toasts, snackbars, alerts, banners, dialogs, inline errors, empty or failed regions, form submission feedback, retry actions, crash or initialization fallbacks, domain-outcome presentation, or mapping technical failures to localized product copy. Trigger even when the request only mentions showError, showSuccess, notifications, server messages, error.message, mutation onError or onSuccess UX, or choosing how and where to tell the user what happened. ---- - -# User Feedback - -Present one clear outcome at the boundary that owns the user interaction. Choose -the surface from the duration, scope, severity, and recovery needs of the -message; do not route every outcome through a toast. - -Feedback is presentation. It does not decide whether a failure is logged, -reported as an incident, retried, or converted. - -## Inspect The Existing Feedback System - -Before adding feedback: - -1. Find the repository's UI primitives and existing toast, snackbar, banner, - alert, dialog, and error-region components. -2. Find any feedback facade or event bus and the runtime composition root that - renders it. -3. Check how localization, accessibility announcements, durations, actions, and - duplicate messages are handled. -4. Trace whether the same interaction already presents inline state or - navigation that makes another message redundant. -5. Find the technical error owner in **error-handling** before adding failure - feedback. - -Reuse the established presentation path. Do not introduce another toast library -or global event bus for one feature. - -## Choose The Smallest Correct Surface - -Use the surface that matches the problem: - -| Surface | Prefer for | -| --- | --- | -| Field message | Validation or server failure tied to one form field | -| Inline region | A failed or unavailable section with local retry | -| Toast or snackbar | A transient action outcome that does not block work | -| Banner | Persistent page- or application-level degraded state | -| Dialog or native alert | A blocking decision or consequence requiring acknowledgement | -| Route or subtree fallback | A render failure that invalidates that region | -| Application fallback | Initialization or unrecoverable root failure | - -Do not use a transient toast for a persistent failure. Do not replace an entire -page when only one region failed. Do not show both an inline message and a toast -for the same outcome unless they serve demonstrably different purposes. - -Read `references/presentation-patterns.md` for adaptable mapping, inline, -mutation, and fallback examples. - -## Use Product Copy, Not Technical Messages - -Do not show `error.message` to the user by default. It may be technical, -unstable, unlocalized, unsafe, or meaningless outside developer diagnostics. - -Prefer: - -1. a localized message selected by a known typed outcome; -2. a user-safe message explicitly guaranteed by the external contract; -3. a localized operation-specific fallback. - -Do not string-match a technical message to choose feedback when a status, code, -or discriminant exists. Do not expose stack traces, request bodies, provider -responses, identifiers the user cannot act on, or secret material. - -## Map Meaningful Outcomes Explicitly - -Keep feedback mapping close to the feature or shared domain boundary that owns -the meaning: - -```ts -function getProjectUpdateFeedback(error: unknown): Feedback { - if (isApprovalRequired(error)) { - return { - kind: "warning", - message: translate("Approval is required before this change can apply."), - } - } - - return { - kind: "error", - message: translate("Unable to update the project."), - } -} -``` - -Promote a mapper to shared `common` code only when genuinely different -consumers need the same meaning. Follow **file-structure** for its placement. - -Keep mapping pure: it selects presentation data. It must not report, log, -navigate, mutate state, or emit the feedback itself. - -## Keep Feedback And Reporting Independent - -Avoid APIs such as: - -```ts -showError(error, { report: true }) -``` - -A presentation helper cannot know whether a query cache, global listener, or -caller already reported the failure. Hidden reporting creates duplicates and -makes UI code control observability policy. - -When one interaction boundary owns both decisions, keep them visible: - -```ts -catch (error) { - reportError(error, { - module: "projects", - context: { projectId }, - }) - - showError({ - message: translate("Unable to update the project."), - }) -} -``` - -If a central observer already reports the failure, call only the feedback path -locally. - -## Treat Success As Optional Feedback - -Do not emit a success toast after every completed operation. - -Skip it when success is already obvious because the UI navigated, closed, -updated the resource, or displayed the new state. Use explicit success -feedback when completion would otherwise be ambiguous, delayed, performed in -the background, or especially consequential. - -Success copy should state the completed result, not merely `"Success"`. - -## Keep Validation Near The Field - -Client validation and server field errors belong beside their fields. Do not -replace them with a global toast. - -Use a form-level or toast failure only for a submission problem that cannot be -assigned to one field. Present one path: after mapping a server error to a -field, do not also show the same failure as a general error. - -Follow **forms** for schemas, React Hook Form, `setError`, submission state, and -field composition. - -## Make Recovery Actionable - -When the user can recover, present the relevant action: - -- retry the failed region or operation; -- reload or restart after an unrecoverable boundary; -- return to a stable route; -- reauthenticate when the session is no longer valid; -- open the resource created by an asynchronous or approval flow; -- dismiss a transient message. - -Do not offer retry when the operation is unsafe to repeat or when -**error-handling** has not established a retry policy. - -Preserve the user's entered data and surrounding usable state whenever -possible. - -## Handle Query And Mutation Feedback Locally - -Central Query or Mutation cache callbacks may own technical reporting, but they -do not know whether a toast, inline state, or no message is appropriate. - -- Present initial query failure in the failed region. -- Preserve cached data during a background refetch failure when possible. -- Present mutation feedback at the interaction boundary. -- Avoid global automatic error toasts for all queries and mutations. -- Keep cache invalidation and canonical lifecycle behavior in - **api-integration**. - -## Keep Feedback Accessible And Localized - -- Use the existing localization system for product copy. -- Ensure dynamic updates are announced through the established accessible - toast, live-region, alert, or native mechanism. -- Keep actionable controls keyboard and screen-reader reachable. -- Do not rely on color or an icon alone to communicate severity. -- Give persistent messages a visible dismissal or recovery path when - appropriate. -- Avoid durations too short for the message or action. - -## Verify The Result - -Before finishing: - -- one outcome produces one presentation; -- the surface matches the scope and persistence of the outcome; -- technical `error.message` is not exposed accidentally; -- copy is localized and actionable; -- known outcomes use typed fields rather than string matching; -- success feedback is not redundant with visible state; -- field errors remain attached to fields; -- feedback does not secretly log or report; -- retry is safe and owned by the technical error flow; -- accessibility and dismissal behavior match existing primitives. - -## Related Skills - -- Catch ownership, reporting, retry, cancellation, and boundaries → - **error-handling**. -- Form validation, submission, and server field errors → **forms**. -- UI primitives, composition, async-region rendering, and fallback components - → **components**. -- Query and mutation lifecycle, typed transport errors, and invalidation → - **api-integration**. -- Translation keys, interpolation, plurals, and locale-reactive copy → - **localization**. -- Placement and promotion of shared feedback modules → **file-structure**. -- Feedback notifications arriving from a native host → - **native-integration**. diff --git a/.claude/skills/user-feedback/agents/openai.yaml b/.claude/skills/user-feedback/agents/openai.yaml deleted file mode 100644 index 2e41ee4..0000000 --- a/.claude/skills/user-feedback/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "User Feedback" - short_description: "Safe and contextual outcome feedback" - default_prompt: "Use $user-feedback to design or review user-facing success, warning, error, and recovery feedback." diff --git a/.claude/skills/user-feedback/references/presentation-patterns.md b/.claude/skills/user-feedback/references/presentation-patterns.md deleted file mode 100644 index 1fab98d..0000000 --- a/.claude/skills/user-feedback/references/presentation-patterns.md +++ /dev/null @@ -1,173 +0,0 @@ -# User Feedback Presentation Patterns - -Adapt these examples to the repository's existing primitives, localization, and -error contracts. The names are illustrative. - -## Contents - -- [Pure Feedback Mapping](#pure-feedback-mapping) -- [Mutation Feedback Without Duplicate Reporting](#mutation-feedback-without-duplicate-reporting) -- [Inline Query Failure](#inline-query-failure) -- [Server Field Error](#server-field-error) -- [Blocking And Root Failures](#blocking-and-root-failures) - -## Pure Feedback Mapping - -Map a known outcome without causing side effects: - -```ts -type Feedback = - | { - kind: "error" - message: string - } - | { - kind: "warning" - message: string - action?: { - label: string - href: string - } - } - -function getSaveFeedback(error: unknown): Feedback { - if (isApprovalRequired(error)) { - return { - kind: "warning", - message: translate("Your change was submitted for approval."), - action: { - label: translate("View request"), - href: `/requests/${error.requestId}`, - }, - } - } - - return { - kind: "error", - message: translate("Unable to save your changes."), - } -} -``` - -The renderer or feedback facade decides how to display the returned structure. -The mapper does not report the error or navigate. - -## Mutation Feedback Without Duplicate Reporting - -When the Query mutation cache already owns technical reporting: - -```ts -try { - await updateProject.mutateAsync({ - projectId, - name, - }) - - closeDialog() -} catch (error) { - const feedback = getSaveFeedback(error) - showFeedback(feedback) -} -``` - -There is no success toast because closing the dialog and updating the project -already make completion visible. - -When no central reporter observes this imperative operation: - -```ts -try { - await exportReport(reportId) - showSuccess(translate("Report exported.")) -} catch (error) { - reportError(error, { - module: "report-export", - context: { reportId }, - }) - - showError({ - message: translate("Unable to export the report."), - }) -} -``` - -## Inline Query Failure - -Keep a local failure inside the region it invalidates: - -```tsx -const content = (() => { - if (query.isPending) { - return <UiSkeleton /> - } - - if (query.isError && query.data === undefined) { - return ( - <UiAlert variant="error"> - <UiAlertTitle>{translate("Projects could not be loaded.")}</UiAlertTitle> - <UiAlertAction onClick={() => void query.refetch()}> - {translate("Try again")} - </UiAlertAction> - </UiAlert> - ) - } - - if (query.data.length === 0) { - return <ProjectsEmptyState /> - } - - return <ProjectsList projects={query.data} /> -})() -``` - -A background refetch failure with existing `query.data` should normally keep -the list visible. Add a subtle stale or retry indication only when it helps the -user. - -## Server Field Error - -Attach a known field failure to the field and stop: - -```ts -catch (error) { - if (isNameTakenError(error)) { - setError("name", { - message: translate("This name is already in use."), - }) - return - } - - showError({ - message: translate("Unable to save the project."), - }) -} -``` - -Do not also show a general toast after setting the field error. - -## Blocking And Root Failures - -Use a dialog or native alert when the user must acknowledge a consequence or -choose an action before continuing. - -Use a route, subtree, or application fallback when rendering or initialization -cannot continue. Keep the fallback stable and offer the narrowest valid -recovery action: - -```tsx -function InitializationFallback() { - return ( - <UiResult> - <UiResultTitle>{translate("The application could not start.")}</UiResultTitle> - <UiResultDescription> - {translate("Restart the application and try again.")} - </UiResultDescription> - <UiButton onClick={restartApplication}> - {translate("Restart")} - </UiButton> - </UiResult> - ) -} -``` - -Do not accompany the fallback with a duplicate toast. diff --git a/.claude/skills/verify-knowledge-quality/SKILL.md b/.claude/skills/verify-knowledge-quality/SKILL.md deleted file mode 100644 index 1e445f0..0000000 --- a/.claude/skills/verify-knowledge-quality/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: verify-knowledge-quality -description: Perform the mandatory two-axis semantic gate for curated workflow knowledge before a document becomes stable or is reported complete. Use after creating or materially editing any product, engineering, decision, reference, or uncertainty concept, or for an explicit quality audit. Review authority and truth independently from reader communication, then bind both passes to one unchanged content hash. Do not use for ordinary explanation and do not let deterministic validation, polished prose, or a single self-review satisfy both axes. ---- - -# Verify Knowledge Quality - -Act as an adversarial review coordinator, not the author defending the draft. -Truth and communication are different failure surfaces; neither may compensate -for the other. - -Read [the quality rubric](references/quality-rubric.md) before the first review -in a session. - -## Freeze the review target - -1. Read the complete target document, not a snippet. -2. Read its parent Area index, every linked current product or engineering - counterpart, every current decision that governs it, and every material - authoritative source. -3. For code-backed claims, invoke `analyze-with-graphify` in the exact pinned - leaf and directly inspect the cited source, tests, and necessary runtime - evidence. -4. Run `wfctl knowledge validate --concept <path>` and distinguish structural - failures from semantic failures. Structural success is necessary but never - sufficient. A page still drafted under a bundle's `promotion/` directory has - no corpus position yet, so its structural validation runs when it is - promoted; a refusal there writes nothing and leaves the page where it is. -5. Run `wfctl knowledge hash --concept <path>` and pin the candidate content - hash before semantic review. The path may be a draft: the hash reads - frontmatter and body, never location, and a promoted page is copied byte for - byte, so a seal bound to the draft still matches once it lands. - -## Run two independent axes - -1. Apply [the authority and truth review](references/authority-review.md). -2. Separately apply - [the reader communication review](references/reader-communication-review.md) - for the declared view and audience. -3. Use independent reviewer contexts when the runtime safely provides them. - Otherwise perform two explicitly separated passes from their own checklists - and evidence packets. Do not reuse the first pass's verdict as evidence for - the second. -4. Search across both passes for omitted exceptions, unsupported present - tense, mixed audiences, hidden implementation detail, intent inferred from - code, history presented as current, and claims broader than evidence. -5. Return one review packet with: - - result: passed or failed; - - separate authority-truth and reader-communication results; - - each failed, uncertain, unread, or blocked check; - - exact evidence and conflicting evidence; - - the smallest correction; - - authority needed from the maintainer, if any. -6. Recompute the content hash. If it changed, discard both passes and rerun - them on the new revision. -7. Do not write a passed receipt while any item is failed, uncertain, unread, - or blocked. - -## Record a passed receipt - -After all substantive content is final: - -1. Run `wfctl knowledge hash --concept <path>`. -2. Set: - -```yaml -x-wf: - quality: - status: passed - by: "<producer>/<version>" - at: "<ISO-8601>" - content_hash: "<wfctl knowledge hash output>" - checks: - - factuality - - audience-fit - - abstraction - - completeness - - delivery-state - axes: - authority-truth: - status: passed - by: "<producer>/<version>" - at: "<ISO-8601>" - content_hash: "<same wfctl knowledge hash output>" - reader-communication: - status: passed - by: "<producer>/<version>" - at: "<ISO-8601>" - content_hash: "<same wfctl knowledge hash output>" -``` - -3. Use the same hash in the normal `verified` event after the applicable - machine or human authority review. -4. Re-run `wfctl knowledge validate --concept <path>`. A material edit changes - the hash and invalidates both receipts. - -The receipt proves that the declared review was performed against one exact -document revision. It does not create authority and does not make an incorrect -review true. diff --git a/.claude/skills/verify-knowledge-quality/agents/openai.yaml b/.claude/skills/verify-knowledge-quality/agents/openai.yaml deleted file mode 100644 index 7d58a0f..0000000 --- a/.claude/skills/verify-knowledge-quality/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Verify Knowledge Quality" - short_description: "Review truth and communication independently" - default_prompt: "Use $verify-knowledge-quality to run separate authority-truth and reader-communication reviews against one unchanged knowledge content hash." diff --git a/.claude/skills/verify-knowledge-quality/references/authority-review.md b/.claude/skills/verify-knowledge-quality/references/authority-review.md deleted file mode 100644 index 47ce721..0000000 --- a/.claude/skills/verify-knowledge-quality/references/authority-review.md +++ /dev/null @@ -1,35 +0,0 @@ -# Authority and truth review - -Review whether the document is entitled to make each claim. Ignore elegance, -tone, and readability except where ambiguity changes meaning. - -## Evidence packet - -Read: - -- the complete target; -- every governing current decision; -- every cited authoritative source in full enough to test the claim; -- current counterpart views and contradictory evidence; -- pinned source, tests, and runtime receipts for implementation claims. - -Retrieval snippets, raw, intake prose, compiled graphs, and agent summaries are -navigation only. - -## Checks - -- Split material statements into atomic claims. -- Match each claim to the correct authority class. -- Confirm source identity, revision, scope, and freshness. -- Ensure accepted intent, observed delivery, and alignment remain independent. -- Reject claims broader than their evidence. -- Find missing conditions, exceptions, failure modes, non-goals, and - contradictions. -- Verify that history is historical and current truth is current. -- Verify decision lineage and explicit maintainer authority for normative - meaning. -- Treat negative or absence claims as unproven without complete applicable - coverage. - -Return `passed`, `failed`, `uncertain`, or `blocked` per check. A missing or -unread source cannot pass. diff --git a/.claude/skills/verify-knowledge-quality/references/quality-rubric.md b/.claude/skills/verify-knowledge-quality/references/quality-rubric.md deleted file mode 100644 index d63ef5f..0000000 --- a/.claude/skills/verify-knowledge-quality/references/quality-rubric.md +++ /dev/null @@ -1,102 +0,0 @@ -# Knowledge quality rubric - -Every item is `passed`, `failed`, `uncertain`, or `blocked`. Only an all-passed -review may create a quality receipt. - -Run the common truth checks as the `authority-truth` axis and the view-specific -checks as the `reader-communication` axis. Bind both results to the same -unchanged content hash. Deterministic validation is a separate structural -gate. - -## Authority-truth axis - -### Factuality - -- Every material current claim has matching authority. -- Sources were read directly and match the exact revision. -- Raw, intake, retrieval snippets, generated graphs, and agent prose are not - treated as authority. -- Conflicting evidence and uncertainty are visible. -- Claims do not exceed the scope of their evidence. - -### Delivery state - -- Accepted intent, observed delivery, and alignment are independent. -- Present tense does not imply unavailable behavior. -- `absent`, `partial`, `implemented`, `verified`, `retired`, and `unknown` - match the actual evidence. -- Planned and rejected behavior remains outside current knowledge. - -### Completeness - -- Important rules, outcomes, boundaries, exceptions, non-goals, failure modes, - and affected relationships were not dropped. -- The document is the smallest coherent unit, not a fragment that hides a - material condition. -- Current decisions and supersession lineage are linked. - -### Freshness and lineage - -- `generated.at`, sources, realization assessment, and linked decisions refer - to the same current state. -- Historical explanation is clearly historical. -- A changed decision updates the current view and preserves predecessor - lineage. - -## Reader-communication axis - -### Product-view checks - -### Audience fit - -- A product manager or client can understand the main answer without - engineering knowledge. -- Domain terms are explained. -- The document leads with product outcome and observable behavior. -- Examples are domain examples, not code or API examples. - -### Abstraction - -- No code, identifiers, paths, endpoints, schemas, protocols, storage - mechanisms, package names, or implementation walkthroughs appear. -- `Engineering details` is link-only. -- Replacing the implementation without changing behavior would not require - rewriting the product explanation. -- Simplification did not erase a rule or exception. - -### Engineering-view checks - -### Audience fit - -- An engineer or operator can locate ownership, implementation, flow, - contracts, failure behavior, and verification. -- Technical terms are precise and useful for maintenance. - -### Abstraction - -- Product meaning is linked, not reconstructed from code. -- The document explains implementation rather than duplicating product prose. -- Technical detail is proportional to maintenance and verification needs. -- Repository, revision, paths, tests, and runtime evidence are pinned where - material. - -### Decision-view checks - -- Context, exact decision, rationale, consequences, affected knowledge, and - lineage are present. -- Alternatives, transition, and unresolved questions are concise and honest - when material; the document does not invent ceremonial content. -- Product effect is understandable without implementation detail. -- Technical consequences link to engineering knowledge. -- Supersession is reciprocal, acyclic, and leaves one stable current record. - -## Evaluation discipline - -This rubric follows agent-evaluation practice: inspect both outcome and -process, combine deterministic checks with semantic and human review, and test -real failure cases rather than relying on self-assessment. - -- Anthropic agent evals: - https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents -- Anthropic skill creation and baseline comparison: - https://github.com/anthropics/skills/blob/main/skills/skill-creator/SKILL.md diff --git a/.claude/skills/verify-knowledge-quality/references/reader-communication-review.md b/.claude/skills/verify-knowledge-quality/references/reader-communication-review.md deleted file mode 100644 index f26e892..0000000 --- a/.claude/skills/verify-knowledge-quality/references/reader-communication-review.md +++ /dev/null @@ -1,40 +0,0 @@ -# Reader communication review - -Review whether the intended reader can recover the correct meaning without -hidden context. Do not accept a document merely because its evidence is strong. - -## Product view - -Evaluate as a product manager, client, maintainer, or domain expert: - -- the purpose and current behavior are clear before history; -- canonical domain terms are defined and used consistently; -- actors, rules, outcomes, boundaries, exceptions, and delivery are visible; -- examples are recognizable domain scenarios; -- technical details do not leak into the explanation; -- planned, partial, absent, retired, and unknown behavior cannot be mistaken - for available behavior; -- links provide optional depth without being required to understand the main - answer. - -## Engineering view - -Evaluate as an engineer or operator: - -- product meaning is linked rather than re-invented; -- ownership, entrypoints, flow, contracts, state, failures, operations, and - verification can be located; -- terminology and boundaries are precise; -- implementation detail is proportional to maintenance needs; -- evidence and revision scope are clear. - -## Decision, reference, and uncertainty views - -- a decision explains the durable choice, rationale, consequences, and - lineage without ceremonial padding; -- a reference distinguishes external fact from project choice; -- an uncertainty states the live question, impact, known facts, and required - authority or evidence. - -Return `passed`, `failed`, `uncertain`, or `blocked` per check. Identify the -smallest wording or structural correction, but do not silently change facts. diff --git a/.claude/skills/verify-project-work/SKILL.md b/.claude/skills/verify-project-work/SKILL.md deleted file mode 100644 index fdf6924..0000000 --- a/.claude/skills/verify-project-work/SKILL.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -name: verify-project-work -description: Verify a change against its contract and its real production path before anyone calls it done. Use when claiming work complete, before promoting what it established into knowledge, or when auditing an implementation against the framing that was approved. ---- - -# Verify Project Work - -A green build proves the build is green. Completion is a claim about what the -product now does, and it holds only because someone looked. - -The tool refuses an incomplete accounting on its own: a stale receipt, an open -issue, an unapproved framing, a dirty checkout, an unaccounted decision. It cannot -tell reading from recording, a check that proves something from one that merely -passes, or a criterion nobody exercised from one that failed. Those are yours, -and they are what this skill is for. - -Read [the completion gate](references/completion-gate.md) when a refusal names a -requirement you have not met, or when deciding what a partial closure must say. - -## Read what the review stage names - -`wfctl work context <id> --stage review` lists what must be accounted for, and -`wfctl work review file` records each one at its current hash. - -A receipt proves accounting. Comprehension has no receipt, which is why reading -is the step and recording is the residue. Read each file to its end: a long issue -tail is where deferred work gets written down, and it is the part a skim reaches -last. Mark a supporting artifact `irrelevant` only when you can say what makes it -irrelevant to this result. - -A receipt binds to the bytes it was taken over, so any file the review itself -edits comes back as changed-after-review and needs reading again. Expect that of -`change.md` in particular: the review is what changes it. - -## Review the contract - -Take each thing that was asked for and find three things: the production -behaviour that delivers it, the evidence that it does, and the path a person -using or operating the product reaches it by. - -Expect the gaps to be quiet. A requirement nothing implements. A behaviour that -stops halfway. Work that arrived unasked. An implementation that looks present -and does the opposite of what was agreed. - -Expected values come from the contract or an independent authority. An expected -value read off the implementation confirms the implementation to itself. - -## Review the engineering - -Invoke Graphify-first analysis, then open the real diff and the production path -in every bound code root: source, callers, boundaries, state, errors, consumers. -Judge project standards, architecture, security, operations and maintainability -on their own, whether or not the spec was followed. Run the focused behaviour -checks and the broader test, build, type, lint and runtime checks. - -Hunt what a passing suite hides — a disabled path, a placeholder, a mock standing -in for the thing, a fixture doing the work, temporary compatibility code, an -unhandled branch, work quietly deferred. `evidence-first` says what each of those -proves and what it does not; here the point is that a review which never went -looking will not find one. - -Ask before you commit. Closure requires a clean checkout whose recorded commit -contains the reviewed implementation, and the gate cannot see whether anyone -agreed to the commit that made it clean. - -For project-only work, verify decisions, knowledge and links, and let code -evidence stay absent rather than invented. - -## Write what the project now knows - -Decide whether verified durable truth changed, and write the pages while the -understanding is fresh. Route product behaviour and engineering realization -through their separate curation skills and run the two-axis quality gate — but -write each page under the bundle's `promotion/` directory, at the exact path it -will occupy inside `knowledge/` — spelled with or without a leading `knowledge/`, -which name the same page. Nothing enters the corpus here. - -A concept promoted from a bundle in a project with no reconstructed baseline has -the same shape as one established by whole-project reading and a far narrower -footing: it came from whatever this task happened to touch. Say so in its -`maintainer-decision` source — name the bundle, state that no reconstruction has -covered this subject — so a later baseline re-derives it instead of trusting it. -Draft it anyway. Knowledge grown from real work beats none, and the shortcut -costs something only while it stays unwritten. - -Account for every answer the maintainer gave with `wfctl work decisions <id>`. A -resolved Wayfinder map is the checklist the accounting is held against, and the -promotion gate holds until every answer has a home. - -Then record what is waiting: - -```sh -wfctl work promotion <id> # from what is on disk -wfctl work promotion <id> --none "<why>" # nothing the project says changed -``` - -## Close it yourself - -Closure asks whether the work matches the framing that was approved, and every -part of that answer is in the record already: verified criteria, passed receipts, -pinned revisions, terminal issues. Do not put it to the maintainer. One night -spent waiting on that question cost seven hours and fifty-four minutes and two of -four approved bundles. - -Finish in this order, because each step invalidates the one before it. Every -semantic edit to `change.md` first. Then `wfctl work checkpoint <id> --stage -review`, before the final hash receipt — a checkpoint edit changes the file's -hash, so a receipt taken ahead of it is stale on arrival. Then re-read -`change.md` and everything else the review changed, and refresh those receipts. -Then: - -```sh -wfctl work verify <id> -wfctl work close <id> --outcome completed|partial|abandoned -``` - -Use the honest outcome. `partial` and `abandoned` are results, and a `completed` -that had to be argued for is the one worth doubting. - -One refusal here is a real question rather than an errand: delivery no longer -matches the approved framing, because the acceptance criteria were reworded since -they agreed to them, or work left the route as a dropped issue. That is the one -thing at the end they alone can settle. Render it with `wfctl work ask <id> ---stage completion` and record their answer with `wfctl work approve <id> --stage -completion --attested "<what they said>"`. - -## Then ask the one question that is theirs - -A closed bundle holding pages waits in the promotion queue rather than the -archive, and the pages wait with it. - -```sh -wfctl work ask <id> --stage promotion -``` - -That packet is the pages themselves, in full, saying of each whether it replaces -something the project already claims. It is the decision that compounds: a -completion receipt is read by an auditor once, and a page is read first by every -session that touches this part of the project. - -The render is only as honest as what it reads. A page that says nothing is shown -as empty, and a draft still carrying its template's words is shown as written. -Repair the page rather than the packet. - -Their word writes it: - -```sh -wfctl work promote <id> --by human:<maintainer-id> --attested "<what they said>" \ - --session "<where they said it>" -``` - -That copies every page into `knowledge/`, validates them, and archives the -bundle. If validation refuses, nothing is written and the bundle stays in the -queue — so fix the page and ask again rather than leaving the corpus half-taught. diff --git a/.claude/skills/verify-project-work/agents/openai.yaml b/.claude/skills/verify-project-work/agents/openai.yaml deleted file mode 100644 index 548e7dc..0000000 --- a/.claude/skills/verify-project-work/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Verify Project Work" - short_description: "Verify every bound source against one spec" - default_prompt: "Use $verify-project-work to verify project-only knowledge work or every bound leaf checkout against the central spec and recorded evidence." diff --git a/.claude/skills/verify-project-work/references/completion-gate.md b/.claude/skills/verify-project-work/references/completion-gate.md deleted file mode 100644 index 9ec1da5..0000000 --- a/.claude/skills/verify-project-work/references/completion-gate.md +++ /dev/null @@ -1,52 +0,0 @@ -# Completion gate - -A completed record requires: - -- every plan and acceptance checkbox resolved; -- the change and every relevant issue have a current structured checkpoint; -- every stable acceptance ID marked verified and paired with passed evidence; -- every bundle file except the review ledger accounted for at its current hash; -- no unseen, changed-after-review, or invalid bundle file; -- every work issue completed or explicitly dropped, with no active claim; -- every acceptance ID covered by a non-dropped delivery issue when issues are used; -- no issue dependency cycle or completed issue with an unresolved blocker; -- any retained Wayfinder map resolved, with no remaining fog; -- at least one relevant Graphify query recorded for code-scoped work; -- `acceptance_reviewed: true`; -- `implementation_reviewed: true` for code-scoped work, or - `knowledge_reviewed: true` for project-only work; -- `maintainer_review.framing.status: approved` with a human actor and timestamp; -- `knowledge_promotion.status: pending` with the pages drafted under the bundle's - `promotion/` directory, `applied` with validated concept paths, or `not-needed` - with a concrete reason; -- delivery that still matches the approved framing. Where the acceptance criteria - have been reworded since the approval, or an issue was dropped from the route, - closure additionally requires `maintainer_review.completion.status: approved` — - it is the one case at the end where what was approved is not what was built; -- one or more fresh checks with commands and outcomes; -- `verification.result: passed`; -- no unresolved item without an explicit accepted disposition; -- a deviations section that says `None` or names every remaining gap. -- every bound source checkout clean so each recorded commit actually contains - its verified implementation; -- a matching revision, worktree ID, and checks receipt for every bound - repository. Single-leaf work may use the top-level verification fields; - multi-repository work uses `verification.repositories`. - -Update semantic records first, refresh the owning checkpoint last, then re-read -the changed record and record its final file receipt. A checkpoint edit changes -the file hash; recording the receipt before the checkpoint would immediately -make that receipt stale. - -Nothing here needs the maintainer. Every item is something the record either -carries or does not, which is why closure is the agent's: a person asked to -confirm this list is signing arithmetic they cannot check better than the tool. -What is theirs is the framing before the work and the pages after it. - -The CLI validates the bundle graph, file hashes, record structure, exact source -bindings, and any promoted concept files. It cannot prove that a conversation -occurred, that semantic evidence is correct, or that no material claim was -omitted. The agent must record a maintainer approval only after an explicit -decision, and the maintainer remains responsible for that decision. - -If a requirement is intentionally dropped, update scope and record who accepted the change. Do not merely check it off. diff --git a/.gitignore b/.gitignore index 583ab9f..294cd45 100644 --- a/.gitignore +++ b/.gitignore @@ -28,13 +28,3 @@ storybook-static/ # Config files .webextrc .webextrc.* - -# wfctl:begin -graphify-out/ -# wfctl:end - -# The stop guard's own state, which is per-session runtime bookkeeping rather than -# configuration. It sits one level below `.workflow/current/` because wfctl reads every -# `*.json` at the top of that directory as a work binding and parsed this as a malformed -# one; a subdirectory is invisible to that scan. -.workflow/current/hooks/ diff --git a/.graphifyignore b/.graphifyignore deleted file mode 100644 index 24169ca..0000000 --- a/.graphifyignore +++ /dev/null @@ -1,39 +0,0 @@ -# wfctl:begin -.workflow/ -.claude/rules/ -graphify-out/ -AGENTS.md -CLAUDE.md -PROJECT_WORKFLOW.md -skills-lock.json -.agents/skills/align-project-knowledge/ -.claude/skills/align-project-knowledge/ -.agents/skills/analyze-with-graphify/ -.claude/skills/analyze-with-graphify/ -.agents/skills/curate-engineering-knowledge/ -.claude/skills/curate-engineering-knowledge/ -.agents/skills/curate-product-knowledge/ -.claude/skills/curate-product-knowledge/ -.agents/skills/curate-project-knowledge/ -.claude/skills/curate-project-knowledge/ -.agents/skills/explore-project-knowledge/ -.claude/skills/explore-project-knowledge/ -.agents/skills/implement-work-item/ -.claude/skills/implement-work-item/ -.agents/skills/manage-project-work/ -.claude/skills/manage-project-work/ -.agents/skills/qmd/ -.claude/skills/qmd/ -.agents/skills/setup-workflow-environment/ -.claude/skills/setup-workflow-environment/ -.agents/skills/shape-project-direction/ -.claude/skills/shape-project-direction/ -.agents/skills/specify-project-change/ -.claude/skills/specify-project-change/ -.agents/skills/split-project-change/ -.claude/skills/split-project-change/ -.agents/skills/verify-knowledge-quality/ -.claude/skills/verify-knowledge-quality/ -.agents/skills/verify-project-work/ -.claude/skills/verify-project-work/ -# wfctl:end diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 0a5cb7f..e51b915 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -14,11 +14,6 @@ "**/__fixtures__/**", "AGENTS.md", "CLAUDE.md", - "PROJECT_WORKFLOW.md", - "skills-lock.json", - ".workflow/**", - ".claude/**", - ".agents/**", "README.md", "CHANGELOG.md", "dist/**", diff --git a/.workflow/.gitignore b/.workflow/.gitignore deleted file mode 100644 index ef1569e..0000000 --- a/.workflow/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -backups/ -current/ diff --git a/.workflow/config.json b/.workflow/config.json deleted file mode 100644 index 90dc1f4..0000000 --- a/.workflow/config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "schemaVersion": 1, - "profile": "leaf", - "installedVersion": "0.8.0", - "skills": { - "scope": "project", - "agents": [ - "codex", - "claude" - ] - }, - "knowledge": { - "path": "../knowledge-humid" - } -} diff --git a/.workflow/rules/evidence-first.md b/.workflow/rules/evidence-first.md deleted file mode 100644 index e7afc11..0000000 --- a/.workflow/rules/evidence-first.md +++ /dev/null @@ -1,67 +0,0 @@ -# Evidence-first reasoning - -Treat claims, plans, existing documents, and prior agent output as unverified until checked. - -For any task that depends on understanding, locating, changing, debugging, -reviewing, or verifying source code: - -1. Invoke `analyze-with-graphify` before reading or searching source code, even - when the maintainer does not mention Graphify. -2. Require it to inspect the current session skill catalog and invoke the - official native `graphify` skill. -3. Stop and tell the maintainer when either the Graphify CLI or native session - skill is unavailable. Offer the supported installation and session-restart path. -4. Use text search only after graph traversal, as a supplementary precision tool. -5. Inspect the actual source reached through the graph and record the query, - pinned revision, paths, symbols, and checks in the active change or curation - record. Graphify output is navigation, not authority. - -Do not use Graphify as the primary analyzer for raw or curated Markdown. -Use QMD for Markdown retrieval and the generated knowledge graph for explicit -relationship expansion. Use the generated claim ledger only for explicit -intake/reconstruction lineage. Neither graph is evidence; read selected files -directly. Before -knowledge-dependent work, require the official native `qmd` skill in the -current session and invoke it. An on-disk skill installed after session start -does not count; stop and request installation or a session restart instead of -inventing a partial QMD procedure. Raw coverage comes from Git-frozen intake -sources and explicit full-file review. Existing-project coverage comes from -exact clean source revisions, Graphify traversal, direct source and test -inspection, Git history review, repository dossiers, cross-repository -reconciliation, and maintainer adjudication. Curated trust comes from OKF -metadata, authoritative provenance, and current verification. QMD rank, -snippets, and its index are never authority. - -Assume no source is in good condition. Documents may reference deleted paths, -specifications may have been rewritten without supersession, notes meant to be -temporary may be the only written intent, and history may record file moves -rather than decisions. Judge each source as it actually is in this project, not -as its kind is supposed to be. - -When a source turns out to be unreliable, that narrows what it can establish and -widens nothing else. Do not answer it by electing a replacement authority: every -source stays a witness, and reconciling them is the work. Keep reading the -degraded source for terminology, chronology, and leads. Where it contradicts -itself over time, reconcile by chronology rather than choosing a version. - -When no source can establish something, say so and record it as unknown. An -honest unknown is a result; a plausible answer assembled from the least-bad -source available is a guess the maintainer can no longer see through. - -Never establish that work is progressing by checking that a process exists, and -never identify a process by matching its name: the pattern matches the shell -doing the checking, so a command that is waiting on itself reports as running. -Ask the data instead — the counters, records, or files the job writes. When a -background command is reported silent, that is a prompt to check, not a finding. -Compare consumed CPU time against elapsed time, read what the job has written, -and only then decide. Do not agree that something is broken because it was -reported, and do not restart healthy work; that costs more than waiting. - -Do not turn mocks, fixtures, fakes, stories, showcase pages, benchmark -harnesses, placeholders, disabled checks, or partial wiring into production -completion claims. Behavior that exists only inside a demonstration surface is -not delivered, and behavior whose only caller is a test is implemented but not -verified — a green suite proves the test passed. Which of the two holds is a -question about what reaches the code, so it takes graph traversal and a recorded -query rather than a file listing. Name missing evidence and unfinished work -directly. diff --git a/.workflow/rules/execution-continuity.md b/.workflow/rules/execution-continuity.md deleted file mode 100644 index 3cd2559..0000000 --- a/.workflow/rules/execution-continuity.md +++ /dev/null @@ -1,83 +0,0 @@ -# Execution continuity - -This applies while executing accepted work: a claimed issue, an approved scope, -a frozen frontier. It does not apply while shaping, specifying, or grilling a -decision with the maintainer. There the question count should be high, one -focused question at a time is correct, and stopping to ask is the work itself. - -Ending a turn is an act, not a default. The whole corpus says how to ask the -maintainer and nothing says when not to, so an unowned pause reads as safe when -it is only cheap. While accepted work remains, hold three lines. - -**Find it before asking it.** Locate the question in the accepted material — -the specification, the issue, the discovery ledger, the parent bundle's -decisions, the case record — and quote the line that answers it. A question the -material already answers is not a question. Absence of a quote is not permission -to ask; it is evidence the search was not done. - -**A report is not the finish line.** Completion is defined by the issue's -acceptance criteria and proven by its terminal status. A finished plan item, a -written summary, a passing subtask, and a refreshed checkpoint are progress. - -**Check that a stop would lose nothing, rather than believing it.** `wfctl -resumable` answers that from the repository: a checkpoint describing a record -that has since changed, an open record that never had one, and work on disk no -checkpoint describes and no commit preserves are three different losses, and the -last is the one a basis digest structurally cannot see. A non-zero exit is not a -finding to report onward — refresh the checkpoint or commit, then end. The -maintainer asking you to wrap up is the failure this replaces. - -**End a turn only when you are waiting on the maintainer.** Ending one hands -control to them, so the test is what you are waiting for and not what you -wrote. Announcing a next action and stopping is the visible half of this; the -larger half announces nothing — "the work continues by itself", "the rest can -wait for the next boundary", a status report that names no blocker — and parks -just as completely, because nothing continues once the turn is over. If you are -not waiting on the maintainer, take the next action you can take alone. - -**Finishing a unit is not finishing.** Completing an issue releases its claim, -so the bundle is left holding ready issues that nobody has claimed — which is -what every long run looks like between units, and the moment a turn is most -likely to end on "next I will do X" and then not. There is no boundary there. -The next unit is available work, and available work is yours. - -**Say why you are stopping, in the record, when you stop.** Prose is not state: -a turn that explains itself and ends has explained itself to nobody, because the -explanation goes with the session. Two sentences end a turn, and they are -different sentences. - -- The maintainer is what the work is missing. Record a blocker on the owning - checkpoint — `--status blocked --blocker "<what you need from them>"` — which - puts the work in their queue and takes it off yours. -- Nothing is missing except this session: the context is spent, or the next unit - will not fit in what is left of it. Record `--handoff "<why this session stops - here>"`, which tells the next session and asks them nothing. It is cleared by - the next checkpoint, so it explains one stop rather than every stop after it. - -A blocker for the second case is a lie that costs them a turn, and silence for -either is what the workflow returns you to. - -**Never wait for a background command by spinning.** A command that outruns its -foreground limit is moved to the background and announces its own completion; -waiting for it is not your job and doing so costs more than it saves. A loop -that polls a file without pausing consumes a whole core, and it takes that core -from the build it is waiting for — a Rust suite was watched this way twice in -one session, for five hundred seconds each, against a compile competing for the -same processor, and the second watch timed out having learned nothing. If you -genuinely must wait on something the host does not announce, pause between -checks rather than spinning, and prefer doing unrelated accepted work. - -**A discovery is not a stop.** Material that contradicts what was accepted is an -entry in the discovery ledger, and the work continues. Stop only when the -decision is irreversible beyond the current bundle, or when no unblocked work -remains — repeated identical failure, unavailable authority, a blocked source. -Discomfort and uncertainty are not on that list. Record the uncertainty as -uncertainty and keep going. - -Questions that genuinely need the maintainer accumulate and are presented at the -frontier, the batch boundary, or review. Adjudication is a recorded claim -awaiting authority, not a halt: continue with every unit that does not depend on -the answer. - -Do not answer this rule with agreement, restate it, or confirm that it is -correct. Apply it and take the next action. diff --git a/.workflow/rules/maintainer-review.md b/.workflow/rules/maintainer-review.md deleted file mode 100644 index ee9f2d1..0000000 --- a/.workflow/rules/maintainer-review.md +++ /dev/null @@ -1,327 +0,0 @@ -# Maintainer review - -OKF records provenance, trust, and lifecycle; it does not provide an approval -process. Follow the project review protocol in `PROJECT_WORKFLOW.md`. - -Require an explicit maintainer decision before: - -- choosing a workflow route when significance is ambiguous; -- starting raw processing, whole-project reconstruction, durable external - research, semantic curation, or broad direction shaping when the maintainer - did not already request that outcome; -- implementing a significant spec whose outcome, scope, exclusions, acceptance - criteria, or material decisions have not already been explicitly accepted; -- writing anything into `knowledge/`, which is the project speaking about itself; -- selecting current truth when evidence cannot resolve chronology or authority; -- closing work whose delivery no longer matches the framing they approved; -- recording `verified` by a `human:<id>` actor. - -Do not request review for freezing a clean Git raw scope, file accounting, QMD -refresh, index or log maintenance, or source-backed drafts that keep their -trust state honest. Raw intake itself never counts as evidence. - -Reading is never a decision. Accounting for what each bound repository declares -about itself, running Graphify in every bound root, checking curated knowledge -before settling meaning — a gate refusing until those are done is telling the -agent to go and do them, not handing the agent a question. Nothing about them -reaches the maintainer: not as a request, not as a status line, and not as an -apology for the delay. The one thing that does reach them is a product -consequence found while reading — a rule in one repository that makes the -proposed route impossible there is a decision, and it is put to them as what the -product must do instead, never as the file it was found in. - -Record approve, correct, or defer. Silence is not approval. - -## Write for someone who was not watching - -The maintainer did not see the tool calls, the gate that refused, or the file -you fixed on the way. They hold no identifier you generated and no count from -your last turn. Every message is read cold, by a person deciding about their -product, and one that catches them up first buries what it came to say. - -A message carries three things in this order, and stops: - -1. **What is true now** that was not true before. One or two lines. -2. **What you need from them**, if anything. One question, one recommendation. -3. **What happens next without them.** One line. - -Everything else goes in the record, which already has a place for each kind of -it: the discovery ledger for what you learned, the checkpoint for where the work -stands, review receipts for what you read, the blocker for what you are waiting -on. Sending it instead is not thoroughness. It moves the reading onto the person -least able to do it, and the record still ends up empty. - -The message is finished when cutting any remaining sentence would change what -the maintainer does next. Cut one and check. If nothing changes, it was written -for you. - -A structured document is the common way to fail this, because it looks like -care. Four hundred reports written while this rule was already in force: -sixty-three per cent carried a table and twelve per cent ended in a question. A -table compares things the reader already cares about. Reached for before there -is a question, it is a log with borders. Write the three lines first, and add a -table only when the answer turns on a comparison. - -**Sentences.** Use the active voice. Give one idea per sentence and keep it to -twenty words. Use simple past, present, and future. Use the same word for the -same thing every time; a synonym reads as a second thing. Drop idioms, slang, -and internal vocabulary. Keep paragraphs to six sentences. - -**Keep technical items exact.** A path, a symbol, a version, a price, or a count -is reproduced character for character or left out. Never paraphrase one into -approximate prose: "about five thousand" and "the pricing worker" cannot be -checked, and being unable to check it is the cost the shortening was supposed to -avoid. What decides whether it appears at all is the reader test below. - -## Ask one thing, and make it cheap to answer - -Give the answer a shape they can hit. Name the options, or say what a bare yes -changes. A question with no stub gets answered with a paragraph you then have to -interpret, which is a second decision you made on their behalf. - -Say plainly that "I do not know" is an answer you can record. A recorded gap is -worth more than a preference guessed from a polite reply and then treated as -settled. - -**Write their reply before you send the message.** Draft the answer you expect, -in their voice, one line. If nothing fits — if the only reply available is -"which one?" or "what specifically?" — then what you wrote is a status line and -the question is still missing. - -Saying that something waits is the common way to fail this, and it fails because -it describes your queue rather than their decision. "Three records are ready and -all three are waiting on you" passes every other rule here: it names no count -alone, it can carry a table, it reads as brevity. It still leaves them to work -out what they are being asked. So does a category — "the boundaries", "a decision -about the deck", "your word on the shape" — which names the subject and withholds -the question. What passes is an alternative with its consequence: "delete the -twenty-two explanations, or give the row a note field; the note is my -recommendation, and it costs one migration." - -This is a test rather than a list of phrases to avoid, because the failure is the -same in every language and rewords faster than any list can be kept. Apply it to -each sentence that puts something to them, including a blocker, a status line, -and the last line of a report. - -## Find their answer before asking for it again - -Run `wfctl knowledge decided "<subject>"` before putting anything to them. It -reads the four places an answer lands — a promoted decision page, the bundle that -asked the question, a resolved Wayfinder map, and a capture — and reports what it -finds with the date, their own words, and whether a curated page carries it. On a -bundle, `--record <id>` writes the result into the framing, and the framing gate -holds until it has. - -Their answer is usually not on a page. Twenty-two of twenty-six recorded -decisions in one project had never been promoted, so a search of curated -knowledge truthfully found nothing and read exactly like a question nobody had -answered. Work already delivered counts the same way: a completed issue that -recorded a consequence as theirs to settle has answered the question once -already, and asking again spends their turn on their own bookkeeping. - -Cite the page when there is one and the record when there is not, and say which. -A decision reachable only through an archive is one the corpus has not been -taught, and saying so is what eventually teaches it. - -## Re-establish the subject before asking about it - -A claim is the unit of record. It is not the unit of a question. Recorded -atomically, a claim keeps only what distinguishes it from its neighbors, which -is exactly what a reader who was not there cannot reconstruct: a packet built -straight from one carries a difference with no thing attached to it. "This is -one colour on one side and another on the other side — was that intended?" is a -complete atomic claim and an unanswerable question. - -Before the packet is written, re-establish four things and put them above the -question: - -- **What it is**, named as the product names it, and where a person using or - operating the product meets it. -- **Where it came from** — which sources say this, and when they said it. -- **What turns on it** — the flow it sits in and what changes downstream if the - answer goes either way. -- **What is true right now.** Go back to the pinned source and the current - implementation and check. The candidate was extracted at reading time, before - later findings, and other candidates recorded since may already contradict or - settle it. A packet that repeats a days-old record without re-reading is - asking the maintainer to adjudicate the agent's memory. - -Ask about the subject and list its atomic claims underneath, rather than asking -about each claim. Several claims about one capability are one question; one -claim touching several capabilities is still one question, about the one thing -that has to be decided. - -Translate rather than cite. A term that exists only in the implementation gets -replaced by what it does for the product, with the original in parentheses at -most once. A subject that cannot be described at all without file paths or -symbol names is a finding to record — the capability has no product-level -description yet — and never a licence to send the identifiers instead. - -## The line, and how to tell which side you are on - -| | Written for the record | Written for the maintainer | -| --- | --- | --- | -| Reader | The next agent, the compiler, an auditor | A person deciding about their product | -| Addresses | **Required.** A claim without its pointer is unverifiable | **None.** An address is something to look up, not something to read | -| Vocabulary | Whatever is exact | Whatever the product itself uses | -| Failure | A missing pointer | A sentence that cannot be answered without opening something | - -Apply one test to every sentence before it reaches the maintainer: - -> Would they have to look something up to understand this? - -If yes, it is an address, whatever form it takes — a file, a symbol, a record id, -a ledger code, a commit, a section number, a milestone or tier code, a schema -value printed as a category, or a term that exists only inside the -implementation. It is a test rather than a list because it reads the reader, not -the shape of the string or the language it is written in. - -Two failure modes, and over-correcting the first produces the second: - -- **Addressed prose.** "The check in the catalog store was narrowed because the - gate validates on publish." Every noun is something to look up. -- **Emptied prose.** "A check was narrowed for a reason that turned out to be - wrong." Nothing to look up and nothing to decide on either. - -What is wanted is neither: say what the thing does for the product, and say it -with enough substance to be judged. "The client stopped checking anything but the -shape of a record, on the belief that publishing would catch the rest — and -publishing does not." The pointer to the file stays in the record, and is brought -out when the maintainer asks to audit rather than to decide. - -An address is never deleted to satisfy this. It moves. A finding whose evidence -was dropped to keep a sentence clean has been made worse, not better. - -Where a packet can be generated from records rather than composed, generate it. -A renderer that never reads an address cannot print one, which is worth more than -care taken one message at a time. Where it must be composed — most places — the -test above is the whole rule, and it is the agent's to apply. - -One decision per packet. Several stacked in one message read as a status report -and get answered as none of them. - -Report waiting decisions by name and subject, or do not report them. Never by -count. "Eleven cases are gate-clean and none can close without your decision" -is accurate, reads as brevity, and works as concealment: it says nothing about -what the eleven contain, and the longer the queue the more it hides. Name each -one and what it turns on, one at a time; if that is too long for a single -message, that is a fact about the backlog, not a reason to compress it into a -number. - -Confirming a written record is the exception, and it is one decision rather than -many. When the material already carries the maintainer's own dated decisions, -ask whether they still stand — once for the record, with named exceptions — -instead of reopening each from scratch. Recovering intent that was never written -and confirming intent that was are different questions, and asking the harder -one when the easier one applies is how a body of accepted work becomes an -unanswered queue. - -## Where the two gates are, and why closure is not one - -A maintainer decides two things about a piece of work. What it is, before it -starts. What the project says about itself afterwards. Everything between those -is the agent's, including the moment the work finishes. - -**Put the framing decision before implementation.** `wfctl work issue claim` -refuses a delivery issue whose framing is unapproved, which is the moment to -present it: the bundle has been read, nothing is in flight, and the maintainer's -absence blocks nothing yet. Approving edits the change record, so refresh its -checkpoint and re-read it before claiming. - -**Closure is arithmetic, so close it.** Whether the acceptance criteria are met, -the receipts carry evidence, every issue is terminal and the revisions are pinned -is what the completion gate itself checks. A maintainer asked to confirm that is -being asked to sign a sum they cannot check better than the tool, and the cost is -not theoretical: four bundles were framed and approved for one unattended night, -two were delivered in sixty-two minutes and stopped at that gate, the other two -were never started, and seven hours and fifty-four minutes passed before anyone -could say the word. Nothing about those two bundles was in doubt. - -**Closure returns to them when delivery drifted from the framing.** That is the -one case at the end where something is genuinely undecided, because what was -approved is not what was built. Two things raise it, both observable: the -acceptance criteria have been reworded, added to or cut since the approval, or -work left the route as a dropped issue. The tool names which, and the completion -approval it then asks for is the same command it always was — now the exception -rather than the toll. - -**Promotion is the gate that compounds.** Approving a completion writes a receipt -an auditor may read once. Approving a page writes what every future session reads -first and what the next framing is aligned against. Draft the pages under the -bundle's `promotion/` directory before closing, record them with `wfctl work -promotion <id>`, and put them to the maintainer with `wfctl work ask <id> --stage -promotion`, which shows the pages themselves rather than a list of paths. Their -word, through `wfctl work promote <id>`, is what writes them into `knowledge/`. - -A bundle closed with pages waiting sits in the promotion queue instead of the -archive, and nothing running is held by it — the code shipped, the issues are -terminal. What it does hold is the next framing approval in the same Area, on the -ground that aligning new work against knowledge already known to be behind is the -alignment telling them something the project has stopped believing. - -The packet ends by telling them a wrong page gets rewritten rather than argued -for, and that is a working sentence: rewrite the draft where it sits in the -queue, reseal it, refresh its review receipt, and run `wfctl work promotion <id>` -again. Their answer is never lost by correcting what it was about. - -Approval and permission to start are different decisions and the record holds -them separately. A maintainer who approves a framing and says the work is not to -begin yet — including one approving only so the bundle stops cluttering their -queue — is parking it: `wfctl work approve ... --park "<why>"`, or `wfctl work -park` afterwards. A parked bundle refuses every delivery claim before any other -gate is even read. Only `wfctl work release --attested "<their words>"` starts -it, and a release is never inferred: not from a truthful answer to an unrelated -question, and not from the condition that held it having cleared. The last time -one was inferred, six commits landed in three source repositories. - -Render every gate with `wfctl work ask <id> [--stage promotion|completion]` -rather than composing one. A framing carries what gets done, what deliberately -does not, what will make it finished, and in what order. A promotion carries the -pages themselves, in full, and says of each whether it replaces something the -project already claims. A completion — asked only where delivery drifted — -carries what the work does now, what it still does not do, what closing it takes -on, and what the project now says that it did not. Nothing else from a record -written for an agent reaches any of them. A section still holding the shipped -template's own words is reported as unwritten rather than read out as scope. A -render is only as honest as the record behind it: repair the record rather than -the packet, because a packet edited by hand is composed again. - -Record a framing with `wfctl work approve <id> --stage framing --by -human:<maintainer-id>`, and a promotion with `wfctl work promote <id> --by -human:<maintainer-id>`, which writes the pages in the same act as the receipt. -Both write the `maintainer_review` entry and the durable approval record the -gates check. A framing approval also digests the acceptance criteria it settled, -which is what later tells a reworded contract from the one they agreed to. - -Pass `--attested "<their answer, word for word>" --session "<where they said -it>"`. That is the ordinary path, because the ordinary case is a maintainer who -already answered in conversation, and sending them to a second terminal to -retype a generated bundle id, a stage name and their own identity relocates the -same answer to a less convenient channel while recording no more than the -attestation does. A typed confirmation and an out-of-band `--token` matching -`WFCTL_APPROVAL_TOKEN` both remain, unequal and recorded as such: they prove a -command ran outside your own writing, which is a stronger record and the -maintainer's to ask for, never your default. Never hand-write -`maintainer_review.status`, `by`, `at`, `method`, or `receipt`: a hand-written -receipt fails verification. Existing explicit maintainer instructions may -satisfy the framing decision, but still record it through the command; do not -ask for the same decision twice. Reopen the gate when the approved framing -changes materially — and where the acceptance criteria are what changed, the tool -reopens it for you at closure rather than trusting anyone to notice. - -Approving edits the change record, so re-read it, refresh its review receipt, -and refresh the checkpoint afterwards. - -A checkpoint blocker is the one sentence written for the maintainer rather than -for the next agent, and the only one that reaches them across a session -boundary. That makes a stale blocker worse than a missing one: a missing blocker -makes the next session look, and a stale blocker makes it act — reopening a -decision already made, or recommending an option since proved impossible. When -an answer, a discovery, or a change removes what a record was waiting for, -rewrite its blocker in the same turn. Nothing validates blocker text against the -record it hangs on; only the person changing the record can keep it true. - -After every material maintainer turn, update the spec's mutable current state -and append the decision/discussion ledger before continuing. Preserve proposed, -approved, rejected, deferred, and superseded outcomes without copying the -conversation transcript. diff --git a/.workflow/rules/project-work-bundles.md b/.workflow/rules/project-work-bundles.md deleted file mode 100644 index 3a8cf30..0000000 --- a/.workflow/rules/project-work-bundles.md +++ /dev/null @@ -1,57 +0,0 @@ -# Central project work bundles - -The agent owns routine `wfctl work` commands and structured records. The -maintainer supplies intent, corrections, authority, and explicit review -decisions; do not make them operate the tracker. - -For significant work, create exactly one bundle under -`changes/active/<change-id>/` before extended discussion. `change.md` is the -parent contract, `map.md` is optional Wayfinder lineage, `issues/` contains -bounded work, `artifacts/` contains referenced support, and `review.md` records -full-file accounting. A leaf stores only an ignored binding pointer. - -After every material maintainer turn or agent investigation cycle, preserve any -new information whose loss could cause repeated material investigation, a -different choice, misunderstanding, or unsafe action. Append it to the owning -change or issue `Discovery ledger` with observation, evidence, implication, -scope, and disposition. The ledger is not a fixed taxonomy or activity log. -Then update current state, decisions, acceptance, progress, and evidence, and -run `wfctl work checkpoint` last. A stale checkpoint blocks later gates. - -After interruption, compaction, or a clean-session start, run `wfctl work -context --stage resume` without an ID. Auto-select only when exactly one active -record is bound here; when several exist, inspect `wfctl work status` and ask -the maintainer rather than guessing. Inspect the reported checkpoint, read -every required file and discovery entry completely, verify the exact claim and -code roots, and resume from the bundle rather than conversation memory. - -Use `changes/inbox/` only for pending captures that have no active or curated -owner. Never duplicate active progress there. Resolve each capture to existing -destinations or discard it with a reason so the inbox remains a real queue. - -Before claiming an issue, record a current review receipt for every required -context file. Claim from the exact bound leaf before code work. Never infer a -worktree from repository name, branch, sibling paths, or bundle location. - -A bundle started from the centre without naming a leaf binds no source -repository, and delivery has nowhere to happen. Give it one from that -repository's own checkout with `wfctl work bind <id>`; `wfctl work rebind` moves -a binding the record already carries and is the wrong verb here. Neither is a -maintainer decision. - -Wayfinder is deliberate and planning-only. It resolves precise question issues -and fog into a reviewed specification before any delivery issue or product-code -implementation begins. - -Before completed closure, enumerate the entire bundle with `wfctl work context ---stage review`, re-read every file, refresh stale receipts, reconcile every -stable acceptance ID against direct evidence, and draft the curated pages this -work changes under the bundle's `promotion/` directory. Then record them with -`wfctl work promotion <id>` and close. Closure needs no maintainer: it asks only -whether the work matches the framing they already approved, and the gates answer -that. The pages are what needs them, and they are asked after the bundle closes. - -A capture is not automatically a chore. When it holds something only the -maintainer can settle, create it with `--awaits maintainer` so it appears in -their queue rather than in agent triage, and say so when reporting. A question -filed as triage is a question nobody knows they were asked. diff --git a/.workflow/rules/project-work-lifecycle.md b/.workflow/rules/project-work-lifecycle.md deleted file mode 100644 index 5849c8b..0000000 --- a/.workflow/rules/project-work-lifecycle.md +++ /dev/null @@ -1,53 +0,0 @@ -# Leaf project work lifecycle - -This checkout is an implementation surface. Its configured knowledge -repository owns significant-work bundles and current project knowledge. - -1. Route the request with `manage-project-work`. -2. For significant work, create or reuse one central bundle and run `wfctl work - status` plus the stage-specific `wfctl work context`. -3. Use `specify-project-change` for a bounded contract or - `shape-project-direction` only for explicit Wayfinder. -4. Align the contract with curated knowledge and analyze source Graphify-first. - An empty or uncovering corpus is a supported state in an existing project: - report that nothing curated covers this work and what the alignment rested on - instead, rather than reporting no conflicts. Recommend a reconstruction when - the gap is material; never treat one as a precondition for the work. -5. Record explicit framing approval before code edits. -6. Use `split-project-change` for multi-session work. It creates central issues, - never leaf-local tickets. -7. Use `implement-work-item` for one frontier issue. Read every required file, - record current receipts, and claim the issue from this exact checkout before - implementation. -8. After material discussion or investigation, preserve consequential new - understanding in the owning record's broad `Discovery ledger`, update the - affected semantic state, then refresh its structured checkpoint last. Never - copy active progress into the inbox. -9. Verify behavior through direct production-path inspection and fresh checks. -10. Preserve implementation in the exact clean Git commit only with normal - maintainer authorization. -11. Run `verify-project-work` across the whole bundle and every bound source - revision, draft the curated pages under the bundle's `promotion/` directory, - record them with `wfctl work promotion <id>`, and close. None of that needs - the maintainer: closure is what the gates already prove, and they prove it - better than a person reading a summary of it at midnight. -12. Put the pages to them with `wfctl work ask <id> --stage promotion` and write - them with `wfctl work promote <id>` once they answer. Until then the closed - bundle waits in the promotion queue rather than the archive, and the pages - wait with it. `knowledge/` never holds a page nobody approved, and the corpus - is never left invalid so that progress can look further along than it is. - A page they send back is rewritten where it sits, resealed, its receipt - refreshed, and recorded again with `wfctl work promotion <id>`. - -On a clean session or resume, run `wfctl work context --stage resume` without an -ID. It may auto-select only one bound active record; multiple records require a -maintainer choice. Read its complete required-file set and discovery ledgers, -then verify status. Before code edits, after any directory or branch change, -after compaction, and before verification, re-run work status. Every reported -code root is an exact workspace. The returned bundle/spec path is for records -only. A worktree is not interchangeable with another checkout of the same -repository. - -Do not claim completion with unseen or stale bundle files, open issues or -claims, uncovered acceptance IDs, unresolved Wayfinder fog, dirty checkouts, or -missing evidence. Use partial or abandoned outcomes instead of relabeling gaps. diff --git a/.workflow/rules/workflow-routing.md b/.workflow/rules/workflow-routing.md deleted file mode 100644 index bfbf0aa..0000000 --- a/.workflow/rules/workflow-routing.md +++ /dev/null @@ -1,28 +0,0 @@ -# Workflow routing - -Classify work before changing product or implementation state. Creating a -`shaping` record is safe recordkeeping, not implementation. - -A read-only question about the project, its current capabilities, or one -product direction is not implementation work. Invoke -`explore-project-knowledge` against the configured knowledge repository and -answer progressively. Do not create a shaping record unless the conversation -turns into a proposed decision or change. - -Use the full workflow when work may change observable behavior, domain meaning, interfaces, data or control flow, persistent state, security, reliability, architecture, operational behavior, or coordination across components or repositories. - -Skip the full workflow only when the change is clearly local and preserves behavior and contracts, such as presentation-only polish, copy edits, formatting, or a mechanical correction with no design choice. - -If classification is uncertain, explain the possible impact and ask the maintainer whether to use the full workflow. Recommend one answer. If the maintainer declines, proceed lightly and offer a pending capture only when a reusable finding has no existing owner. Use `wfctl work capture add`; never duplicate active work in `changes/inbox/` or present the capture as authoritative history. - -Never use task size alone as the classifier. A one-line contract change can be significant; a large mechanical rewrite can be lightweight. - -Once work is classified as significant, create its central change bundle before -extended discussion so requirements, issues, decisions, and progress survive -session compaction. - -When a consequential initiative is too uncertain to define acceptance criteria -without guessing across several dependent choices, recommend -`shape-project-direction`. Start Wayfinder only after maintainer agreement, -keep its map and issues in the same central bundle, and do not edit code until -the resolved route has been synthesized into a bounded specification. diff --git a/.workflow/runtime/guard-background-bash.mjs b/.workflow/runtime/guard-background-bash.mjs deleted file mode 100644 index 512dbba..0000000 --- a/.workflow/runtime/guard-background-bash.mjs +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env node -// PreToolUse hook: put a stall watch around every shell command. -// -// A background command has no deadline and no stall detection, so one that -// stops progressing is never heard from again. Foreground commands are not -// exempt: the host moves one to the background once it runs long enough, and -// the watch has to be in place before that happens rather than after. Wrapping -// everything is also what keeps this free of guesses about which commands are -// worth watching. -// -// The watch does not judge the command; it reports so the agent can check. -// -// This runs before every tool call, so it must never fail and never block: -// any unexpected input produces no decision and the call proceeds unchanged. - -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const GUARD = join(dirname(fileURLToPath(import.meta.url)), "idle-guard.sh"); -// Must stay under the host's foreground limit; see idle-guard.sh for why equal -// values silence the watch entirely. -const IDLE_SECONDS = process.env.WFCTL_IDLE_GUARD_SECONDS || "240"; - -function shellQuote(value) { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - -function main() { - let payload; - try { - payload = JSON.parse(readFileSync(0, "utf8")); - } catch { - return; - } - if (payload?.tool_name !== "Bash") { - return; - } - const input = payload.tool_input; - if (!input) { - return; - } - const command = typeof input.command === "string" ? input.command : ""; - if (!command.trim() || command.includes("idle-guard.sh")) { - return; - } - - process.stdout.write(`${ - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "allow", - updatedInput: { - ...input, - command: `IDLE=${IDLE_SECONDS} bash ${shellQuote(GUARD)} --shell ${ - shellQuote(command) - }`, - }, - }, - }) - }\n`); -} - -try { - main(); -} catch { - // A hook that fails is worse than a hook that abstains. -} -process.exit(0); diff --git a/.workflow/runtime/guard-stop.mjs b/.workflow/runtime/guard-stop.mjs deleted file mode 100644 index 64f107c..0000000 --- a/.workflow/runtime/guard-stop.mjs +++ /dev/null @@ -1,316 +0,0 @@ -#!/usr/bin/env node -// Stop hook. Autonomous work dies when a turn ends while nothing is blocked: -// nothing failed, the transcript simply stops, and hours pass before anyone -// notices. Instructions do not fix it — the managed agent block already says to -// continue and is ignored. What fixes it is costing the model another turn, -// because inside that turn the next action is the cheapest thing to do. -// -// The question asked is whether the agent is waiting on the maintainer, not -// what its last message said. Framing it around a stated next action missed the -// larger half of the failure: a turn that ends on "the work continues by -// itself" or "the rest can wait" announces nothing, blocks on nothing, and -// parks just as completely. -// -// This never decides whether the work is done. It reports what the turn ended -// with and what the repository says is outstanding, and hands the judgment -// back. Deciding completion here is exactly how a Stop hook burns a session: -// a hook that keeps answering "not finished" forces turns the model cannot -// satisfy until the token cap ends it. -// -// The bound is progress rather than a single re-entry. One re-entry was the -// first attempt and it was too weak: an agent re-entered once, did real work, -// stopped again, and the second stop passed unconditionally, so the run parked -// itself for the night with the frontier still full. Progress is observable -// without judging anything — the state report either moved between two stops or -// it did not — so re-entry continues while the repository keeps changing and -// releases the moment it stops, under a hard ceiling that guarantees the turn -// always ends. -import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -const MESSAGE_LIMIT = 600; -const MAX_REENTRIES = 100; -const BLOCK_HISTORY = 50; - -function allow() { - process.exit(0); -} - -function main() { - let input; - try { - input = JSON.parse(readFileSync(0, "utf8")); - } catch { - allow(); - return; - } - - // Waiting on a background task is a legitimate reason for a short turn; the - // host re-invokes the agent when the task finishes. - if (Array.isArray(input.background_tasks) && input.background_tasks.length > 0) { - allow(); - return; - } - - const cwd = input.cwd || process.cwd(); - // Turned off deliberately. The switch is a marker file rather than the absence - // of the settings entry, because an upgrade reinstalls the entry and would - // silently undo the maintainer's choice. - if (disabled(cwd)) { - allow(); - return; - } - const report = readState(cwd); - if (!report) { - allow(); - return; - } - // Every signal that awaits the agent arms this, including the ones that look - // like housekeeping. Filtering by level was the wrong trade: a spent turn - // costs seconds and the failure it catches costs a day. A signal awaiting the - // maintainer stays out — that is a question for them, and forcing a turn on - // it would only make the agent answer itself. - const awaiting = (report.signals ?? []).filter((signal) => signal.awaits === "agent"); - if (awaiting.length === 0) { - allow(); - return; - } - - const fingerprint = stateFingerprint(report); - const key = `${input.session_id ?? ""}:${input.prompt_id ?? ""}`; - const previous = readMemory(cwd); - const carried = previous.key === key - ? previous - : { key, count: 0, fingerprint: "", answer: "" }; - const answer = createHash("sha256") - .update(input.last_assistant_message ?? "") - .digest("hex"); - - const remembered = writeMemory(cwd, { - key, - count: carried.count + 1, - fingerprint, - answer, - }); - if (input.stop_hook_active) { - // Without durable memory there is no way to tell a productive continuation - // from a stuck one, so fall back to the weaker single re-entry rather than - // risk a turn that cannot end. - if (!remembered) { - allow(); - return; - } - if (carried.fingerprint === fingerprint) { - // The last re-entry changed nothing the repository can see. Asking again - // would be asking the same question of the same state. - writeMemory(cwd, { key, count: 0, fingerprint, answer }); - allow(); - return; - } - if (carried.answer === answer) { - // The repository moved but the agent gave the same answer, which is what - // a genuinely stuck one does while something else writes underneath it. - writeMemory(cwd, { key, count: 0, fingerprint, answer }); - allow(); - return; - } - if (carried.count >= MAX_REENTRIES) { - // A runaway backstop and nothing more. It was six, chosen from a rigged - // test where the state moved on its own while the agent was stuck, and it - // became the only bound that ever fired: a productive overnight run hit - // it after six re-entries and parked for nine hours with work left. The - // two content bounds above are the real ones — unchanged state and a - // repeated answer both mean the next re-entry buys nothing — so this only - // has to guarantee the turn ends. - writeMemory(cwd, { key, count: 0, fingerprint, answer }); - allow(); - return; - } - } - - recordBlock(cwd, { - at: new Date().toISOString(), - session: input.session_id ?? "", - reentry: carried.count + 1, - awaiting: awaiting.map((signal) => ({ id: signal.id, subject: signal.subject ?? "" })), - }); - - process.stdout.write(JSON.stringify({ - decision: "block", - reason: reason(input.last_assistant_message ?? "", awaiting), - })); - process.exit(0); -} - -/** - * Every block, with what armed it. Deciding whether this guard needs a way to - * end a turn that is not a blocker takes evidence about the blocks it actually - * makes, and the only alternative on offer was re-reading session transcripts by - * hand and hoping the interesting one was among them. - * - * Bounded and rewritten whole: a log nobody prunes becomes its own problem, and - * the recent blocks are the ones that answer anything. - */ -function recordBlock(cwd, entry) { - try { - const path = join(cwd, ".workflow/current/hooks/stop-guard-blocks.json"); - let history = []; - try { - const parsed = JSON.parse(readFileSync(path, "utf8")); - if (Array.isArray(parsed)) { - history = parsed; - } - } catch { - // A first block, or a file this run is about to replace anyway. - } - history.push(entry); - mkdirSync(dirname(path), { recursive: true }); - const temporary = `${path}.tmp`; - writeFileSync(temporary, `${JSON.stringify(history.slice(-BLOCK_HISTORY), null, 1)}\n`, "utf8"); - renameSync(temporary, path); - } catch { - // Recording is for us, never for the turn. A hook that fails here would - // cost the run something the evidence is not worth. - } -} - -function disabled(cwd) { - try { - readFileSync(join(cwd, ".workflow/current/hooks/stop-guard.disabled"), "utf8"); - return true; - } catch { - return false; - } -} - -function readState(cwd) { - const result = spawnSync("wfctl", ["brief", "--json"], { - cwd, - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - if (result.status !== 0 || !result.stdout) { - return undefined; - } - try { - return JSON.parse(result.stdout); - } catch { - return undefined; - } -} - -/** - * Everything the collectors observed, minus the timestamp that changes on every - * run. Counters inside signal facts — files reviewed, packets accepted, pending - * captures — move whenever work lands, so this distinguishes a turn that did - * something from a turn that only spoke. - */ -function stateFingerprint(report) { - return createHash("sha256") - .update(JSON.stringify(report.signals ?? [])) - .digest("hex"); -} - -/** - * Under `.workflow/current/`, which is gitignored, but one level down: in a leaf - * repository wfctl reads every `*.json` at the top of that directory as an - * active-work binding, so a state file left there broke every `wfctl work` - * command with "Unsupported or malformed active work binding". A subdirectory - * is invisible to that scan. - * - * `.workflow/runtime/` looks like the obvious home and is the wrong one: it - * holds installed assets that upgrades own and Git tracks, so mutable state - * there both dirties the tree and turns every upgrade into a conflict. - */ -function memoryPath(cwd) { - return join(cwd, ".workflow/current/hooks/stop-guard.json"); -} - -function readMemory(cwd) { - try { - const value = JSON.parse(readFileSync(memoryPath(cwd), "utf8")); - return { - key: typeof value.key === "string" ? value.key : "", - count: Number.isInteger(value.count) ? value.count : 0, - fingerprint: typeof value.fingerprint === "string" ? value.fingerprint : "", - answer: typeof value.answer === "string" ? value.answer : "", - }; - } catch { - return { key: "", count: 0, fingerprint: "", answer: "" }; - } -} - -function writeMemory(cwd, value) { - try { - const path = memoryPath(cwd); - mkdirSync(dirname(path), { recursive: true }); - const temporary = `${path}.tmp`; - writeFileSync(temporary, `${JSON.stringify(value)}\n`, "utf8"); - renameSync(temporary, path); - return true; - } catch { - return false; - } -} - -function reason(message, awaiting) { - const tail = message.length > MESSAGE_LIMIT - ? `…${message.slice(-MESSAGE_LIMIT)}` - : message; - const outstanding = awaiting - .map((signal) => ` - ${signal.summary}${signal.subject ? ` (${signal.subject})` : ""}`) - .join("\n"); - // Written as targets rather than bans. Steering by prohibition drags the - // forbidden behaviour into context and makes it more available: the ban - // half-reads as an instruction to do the thing. This message closed on four - // prohibitions in one sentence — acknowledge, agree, explain, answer empty — - // and collected all four in the wild. - return [ - "Automatic turn check from wfctl. This is the workflow speaking, not the", - "maintainer.", - "", - "The turn ended with this text:", - tail, - "", - "The repository reports work awaiting the agent:", - outstanding, - "", - "Ending a turn hands control to the maintainer. The test is whether you are", - "waiting on them, and the list above is the evidence. When you can act alone,", - "act: take the next action, whether you named it or not.", - "", - "Two answers end this check, and they are different answers.", - "", - "The maintainer is what the work is missing. Record it as a blocker, which", - "puts the work in their queue:", - "", - " wfctl work checkpoint <id> --actor <you> --status blocked \\", - " --blocker \"<what you need from them>\" \\", - " --state \"<where the work stands>\" --next \"<what happens once they answer>\"", - "", - "Nothing is missing except this session — the context is spent, or the next", - "unit does not fit in what is left. Record that instead, which tells the next", - "session and asks the maintainer nothing:", - "", - " wfctl work checkpoint <id> --actor <you> \\", - " --handoff \"<why this session stops here>\" \\", - " --state \"<where the work stands>\" --next \"<the next unit, named>\"", - "", - "Only the record ends this. An agent blocked on a person wrote what it needed", - "in nine consecutive messages and was returned nine times, because the", - "repository kept reporting the work as its own. A handoff is cleared by the", - "next checkpoint, so it says why you stopped here rather than granting a", - "standing licence to stop.", - "", - "Either way, run `wfctl resumable` before you end. It answers whether stopping", - "now would lose anything; on a non-zero exit, refresh the checkpoint or commit,", - "then end.", - "", - "This check returns while each turn moves the repository, and releases on the", - "first turn that does not. Answer with the next action, taken.", - ].join("\n"); -} - -main(); diff --git a/.workflow/runtime/idle-guard.sh b/.workflow/runtime/idle-guard.sh deleted file mode 100644 index f0a00b4..0000000 --- a/.workflow/runtime/idle-guard.sh +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env bash -# Report a background command that has gone silent. Never decide for the agent. -# -# Duration is the wrong test: a talkative hour-long build is healthy and a -# silent loop is not. Silence only means nobody can tell from out here, so this -# reports and exits while the child keeps running. Exiting is the point — a -# finished background task is the only channel that reaches a working agent. -# -# The child writes to a regular file, never to a pipe. A stalled writer on a -# pipe blocks whoever reads it, which is how an earlier version of this script -# hung in exactly the way it exists to catch. - -set -u -# Below whatever the host allows a foreground command before moving it to the -# background, deliberately. At the same number this can never speak first: the -# command is taken away in the same second the watch would have reported, so a -# build that went quiet is only ever heard about from the host, and the report -# this exists to make never arrives. Four minutes of total silence is long -# enough that a healthy compile — which prints as it goes — does not trip it. -IDLE="${IDLE:-240}" -SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") -POLL="${IDLE_GUARD_POLL:-2}" - -usage() { - echo "usage: idle-guard.sh --shell <command-string>" >&2 - echo " idle-guard.sh --watch <pid> --log <path>" >&2 - exit 64 -} - -# Progress is the two streams together; they stay separate everywhere else, -# because merging them would change what the caller sees on a healthy run. -log_size() { - local total=0 file - for file in "$@"; do - total=$(( total + $(wc -c <"$file" 2>/dev/null | tr -d ' ' || echo 0) )) - done - printf '%s' "$total" -} - -# Consumed CPU in seconds. A process that prints a heartbeat while blocked looks -# healthy to a silence test and stalled to this one. -cpu_seconds() { - local raw - raw=$(ps -o time= -p "$1" 2>/dev/null | tr -d ' ') || return 1 - [ -n "$raw" ] || return 1 - printf '%s' "$raw" | awk -F: '{s=0; for(i=1;i<=NF;i++) s=s*60+$i; printf "%d", s}' -} - -# Fast commands must not pay for the watch, long ones must not spin: start -# tight, relax once it is clear this is not a quick command. -poll_interval() { - local ran="$1" - [ -n "$POLL" ] && { printf '%s' "$POLL"; return; } - if [ "$ran" -lt 5 ]; then printf '0.1' - elif [ "$ran" -lt 30 ]; then printf '1' - else printf '5' - fi -} - -report() { - local pid="$1" started="$2" log="$3" command="$4" trigger="$5" - local err="${log}.err" - local elapsed=$(( $(date +%s) - started )) - { - echo "idle-guard: ${trigger} — this is a prompt to check, not a verdict." - echo " command : ${command}" - echo " pid : ${pid} still running, untouched" - echo " elapsed : ${elapsed}s" - ps -o time=,%cpu=,state= -p "$pid" 2>/dev/null \ - | sed 's/^ *//' | sed 's/^/ cpu,%,state: /' - echo " full log : ${log} (stderr: ${err})" - echo " last output:" - tail -n 5 "$log" "$err" 2>/dev/null | grep -v '^==>' | sed 's/^/ | /' - echo - echo " Silence is not evidence of failure. Do NOT kill or restart on this" - echo " report alone, and do NOT agree that something is wrong because it was" - echo " reported. Establish it: compare cpu time against elapsed, then check" - echo " the data the job writes. Never check by matching a process name — a" - echo " pattern matches the checking shell too, which is how silent waiting" - echo " gets mistaken for progress. Restarting healthy work is the more" - echo " expensive mistake." - echo - echo " re-arm : IDLE=${IDLE} bash \"${SELF}\" --watch ${pid} --log ${log} &" - echo " abandon: kill -KILL ${pid}" - } >&2 -} - -watch_pid() { - local pid="$1" log="$2" command="$3" started="$4" - local last size cpu_last cpu_at now quiet stalled - last=$(date +%s); cpu_at=$last - size=$(log_size "$log" "${log}.err"); cpu_last=$(cpu_seconds "$pid" || echo 0) - while kill -0 "$pid" 2>/dev/null; do - sleep "$(poll_interval $(( $(date +%s) - started )))" - now=$(log_size "$log" "${log}.err") - if [ "$now" != "$size" ]; then size="$now"; last=$(date +%s); fi - stalled=$(cpu_seconds "$pid" || echo "$cpu_last") - if [ "$stalled" != "$cpu_last" ]; then cpu_last="$stalled"; cpu_at=$(date +%s); fi - - quiet=$(( $(date +%s) - last )) - if [ "$quiet" -ge "$IDLE" ]; then - report "$pid" "$started" "$log" "$command" "no output for ${quiet}s" - return 125 - fi - # A CPU stall was tried as a second trigger and dropped: I/O-bound work — - # downloads, network waits, a shell loop around sleep — consumes almost no - # CPU while progressing perfectly well, so it fired on healthy commands. - # Consumed CPU stays in the report, where it tells the agent whether a - # silent process is waiting or working. It is evidence, not a verdict. - done - return 0 -} - -case "${1:-}" in ---shell) - [ $# -eq 2 ] || usage - command="$2" - log="${TMPDIR:-/tmp}/idle-guard.$$.log" - : >"$log" - : >"${log}.err" - # Bash hands a background job /dev/null unless stdin is redirected explicitly, - # which would silently starve any command that reads it. - if command -v stdbuf >/dev/null 2>&1; then - stdbuf -oL -eL bash -c "$command" >"$log" 2>"${log}.err" <&0 & - else - bash -c "$command" >"$log" 2>"${log}.err" <&0 & - fi - child=$! - tail -n +1 -f "$log" 2>/dev/null & - streamer=$! - tail -n +1 -f "${log}.err" >&2 2>/dev/null & - streamer_err=$! - disown "$streamer_err" 2>/dev/null || true - # Drop it from the job table so terminating it does not print job-control - # noise into the output the agent reads. - disown "$streamer" 2>/dev/null || true - started=$(date +%s) - watch_pid "$child" "$log" "$command" "$started" - stalled=$? - kill "$streamer" "$streamer_err" 2>/dev/null - if [ "$stalled" -eq 125 ]; then - # Leave the child and its log in place: the agent decides, and --watch - # re-arms onto the same pair without losing a second of work. - exit 125 - fi - wait "$child" - code=$? - rm -f "$log" "${log}.err" - exit "$code" - ;; ---watch) - [ $# -eq 4 ] && [ "$3" = "--log" ] || usage - pid="$2" - log="$4" - started=$(date +%s) - watch_pid "$pid" "$log" "(re-armed watch on pid $pid)" "$started" - stalled=$? - [ "$stalled" -eq 125 ] && exit 125 - echo "idle-guard: pid ${pid} finished; its output is in ${log}" >&2 - exit 0 - ;; -*) - usage - ;; -esac diff --git a/.workflow/state.json b/.workflow/state.json deleted file mode 100644 index f205cfc..0000000 --- a/.workflow/state.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "schemaVersion": 1, - "installedVersion": "0.8.0", - "profile": "leaf", - "files": { - ".claude/rules/evidence-first.md": { - "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" - }, - ".claude/rules/execution-continuity.md": { - "sha256": "164105e87a8f79fc5a004146f1cd22950b90d3bada7061b1a61dc6f8467396ee" - }, - ".claude/rules/maintainer-review.md": { - "sha256": "0c289acdb9624926261d45f68da7fe0215da066088eaafb3d477bb6b10c09eff" - }, - ".claude/rules/project-work-bundles.md": { - "sha256": "f1598bedea4647318e5d28c4f65cc5a4369825a4324e98df3478d95a4d7327fe" - }, - ".claude/rules/project-work-lifecycle.md": { - "sha256": "3bcfe1d61b1e52c02afc5b08b381ed4c6c942eaac2f6c170e70817fd33322b8b" - }, - ".claude/rules/workflow-routing.md": { - "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" - }, - ".workflow/.gitignore": { - "sha256": "61bff3ecb1270f462919509a371a296d1dc155a9e7306931191cdb8584053532" - }, - ".workflow/rules/evidence-first.md": { - "sha256": "a94b241d4cee56b3617a564e94911e48e63c35641de446b59a0f4b115cb5d558" - }, - ".workflow/rules/execution-continuity.md": { - "sha256": "164105e87a8f79fc5a004146f1cd22950b90d3bada7061b1a61dc6f8467396ee" - }, - ".workflow/rules/maintainer-review.md": { - "sha256": "0c289acdb9624926261d45f68da7fe0215da066088eaafb3d477bb6b10c09eff" - }, - ".workflow/rules/project-work-bundles.md": { - "sha256": "f1598bedea4647318e5d28c4f65cc5a4369825a4324e98df3478d95a4d7327fe" - }, - ".workflow/rules/project-work-lifecycle.md": { - "sha256": "3bcfe1d61b1e52c02afc5b08b381ed4c6c942eaac2f6c170e70817fd33322b8b" - }, - ".workflow/rules/workflow-routing.md": { - "sha256": "98f929de44907dcafb3e460e6ef2912b1e812397020ea8738364117fb7b5b23c" - }, - ".workflow/runtime/guard-background-bash.mjs": { - "sha256": "9198433e2a84e5cba17f2779aa9422f45f5e68d9bbe66d3084df3d9586cc016d" - }, - ".workflow/runtime/guard-stop.mjs": { - "sha256": "57f0059be7042f62f8a04b2f42ec72578be16580829567d986ba38eaa4a4ca5e" - }, - ".workflow/runtime/idle-guard.sh": { - "sha256": "c2cb5ae64478631b10f86ce5dcd2f49241e9da8ba84fad6b1c3ffcc49f632a6b" - } - } -} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 8a6c24f..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,236 +0,0 @@ -<!-- wfctl:begin --> -## Project workflow - -This block is managed by `wfctl`. Read `.workflow/config.json` and all files under `.workflow/rules/` before project work. Use `PROJECT_WORKFLOW.md` as the maintainer-facing contract for review gates. - -- Invoke `analyze-with-graphify` before inspecting, searching, planning, - changing, debugging, reviewing, or verifying source code, even when the - maintainer does not mention Graphify. -- Require that skill to confirm and invoke the official native `graphify` skill - exposed in the current session before project analysis continues. -- Treat Graphify as the primary source-code navigation tool; text search is - supplementary and direct source inspection is authoritative. -- Do not use Graphify as the primary analyzer for Markdown intake or curated - knowledge. -- Use QMD from the knowledge repository for Markdown retrieval. Treat its - index, ranking, and snippets as navigation only; verify by direct reading and - authoritative sources. -- Present bounded review packets and require explicit maintainer decisions at the gates defined by the workflow. -- Approving a framing settles what the work is, never that it begins. When the - maintainer approves and says starting is premature — including approving only - to clear their queue — record both: `wfctl work approve ... --park "<why>"`. - A parked bundle refuses every delivery claim, and only `wfctl work release - --attested "<their words>"` starts it. Never infer a release from an answer to - a different question, and never from the reconstruction or blocker that held - it having since cleared: the condition ending is not the same as being told to - go. -- Ask the maintainer two things, and closure is neither of them. A framing, with - `wfctl work ask <id>`: what gets done, what deliberately does not, what makes it - finished, in what order. A promotion, with `--stage promotion`: the pages this - work would write into curated knowledge, in full, and what each replaces. Both - are rendered from the record rather than composed; when a render reads wrong, - repair the record it read, because a packet edited by hand is composed again - and composed is what put file paths and criterion ids in front of them. -- Close finished work yourself. Whether the criteria are met, the receipts carry - evidence and the revisions are pinned is what the gates already check, and - asking the maintainer to confirm arithmetic is not a decision. One night this - cost seven hours and fifty-four minutes: two of four approved bundles were - delivered in an hour, stopped at a gate only a sleeping person could open, and - the other two were never started. Closure returns to them in exactly one case, - and the tool names it: delivery no longer matches the framing they approved, - because the criteria were reworded or work was dropped from the route. -- Draft the curated pages before closing, under the bundle's `promotion/` - directory, at the path each will occupy inside `knowledge/`. Then run `wfctl - work promotion <id>`, which records them from what is on disk, or `--none - "<why>"` when this work changes nothing the project says about itself. A closed - bundle holding pages waits in the promotion queue rather than archiving, and - `wfctl work promote <id>` writes them on the maintainer's word. -- Record approvals with the commands, never by editing `maintainer_review`; a - hand-written receipt fails verification. Pass `--attested "<their answer, word - for word>" --session "<where they said it>"` when they answered in the session, - which is the ordinary case. Do not send them to a second terminal: retyping a - generated bundle id, a stage name and their own identity records no decision - the attestation does not. A typed confirmation or `--token` remains available - and is theirs to ask for, never your default. -- Run `wfctl knowledge decided "<subject>"` before putting any question to the - maintainer, whatever route you are on. It reads the four places an answer lands - — a promoted page, the bundle that asked, a resolved map, a capture — plus work - already delivered, and reports the date and their own words. Most answers are - not on a page, so a search of curated knowledge alone finds nothing and reads - like a question nobody has answered. On a bundle, `--record <id>` writes the - result into the framing, which the framing gate requires. -- Ask one material question at a time, include a recommendation, and update - the durable record before continuing. Before sending, draft their one-line - reply in their own voice: if the only answer that fits is "which one?", you - wrote a status line and the question is still missing. Saying that something - waits on them is not asking. A count fails this test and so does a category — - "the boundaries", "your word on the shape" — while an alternative with its - consequence passes. It is a test rather than a list of phrases, because the - failure is identical in every language. -- Write to a maintainer who was not watching. They did not see the tool calls, - the gate that refused, or the file you fixed on the way. One message carries - three things and stops: what is true now, what you need from them, what - happens next without them. Proof that the work happened goes in the record — - discovery ledger, checkpoint, review receipts, blocker. Cut any remaining - sentence and ask whether it changes what they do next. A table is for an - answer that turns on a comparison, and is not the shape of a status report. - Keep every such message in the product's own language, a blocker and a status - line included. Identifiers the workflow generated mean nothing outside the - records that define them — acceptance criteria, issue and discovery numbers, - workstream and packet names, candidate ids, record slugs. Name the thing first - and attach the identifier after it, if at all: "the approval gate the tests - cannot open (AC-04)", never "blocked on AC-04". -- Preserve uncertainty and report missing evidence instead of guessing. -- Execute required `wfctl` commands yourself when tool access permits. Do not - delegate routine CLI operation, spec editing, or record maintenance to the - maintainer; ask them for decisions, approval, or missing authority. -- Treat the maintainer's natural-language request as the user interface. - Outside bootstrap or explicit troubleshooting, never require them to know a - subcommand, record ID, generated path, QMD query, Graphify invocation, or - structured-file schema. Resolve those mechanics yourself. -- End a turn only when you are waiting on the maintainer. Ending one hands - control to them, so the test is not whether you announced anything: a turn - that closes with "the work continues" or "the rest can wait" parks just as - completely as one that names a next step and abandons it. If you are not - waiting on them, take the next action you can take alone, in the same turn. - A written report is progress and never the finish line; completion is the - terminal status of the required records. This holds while executing accepted - work and not while shaping or specifying, where asking is the work. When - several materially different choices remain, present their human meaning, - evidence, and recommendation; after the maintainer chooses, execute the - corresponding commands yourself. -- Finishing a unit is not finishing. Completing an issue releases its claim, so - the bundle is left holding ready issues nobody has claimed — the shape every - long run passes through between units, and the moment a turn is most likely to - end on "next I will do X" and then not. The next unit is available work, and - available work is yours. -- Say why you are stopping, in the record, in the same turn. Prose is not state: - an explanation that lives only in a message goes with the session. Two answers - end a turn and they are different answers. The maintainer is what the work is - missing — record a blocker: `wfctl work checkpoint <id> --status blocked - --blocker "<what you need from them>"`, which moves it to their queue. Or - nothing is missing except this session, because the context is spent or the - next unit will not fit in what is left — record `--handoff "<why this session - stops here>"`, which tells the next session and asks them nothing. A handoff is - cleared by the next checkpoint, so it explains one stop rather than every stop - after it. Using a blocker for the second costs them a turn on a question that - was never theirs. -- Before ending a turn, run `wfctl resumable`. It answers, from the repository - rather than from your memory of it, whether stopping now would lose anything: - a checkpoint describing a record that has since changed, an open record that - never had one, or work on disk no checkpoint describes and no commit preserves. - A non-zero exit is not a report to pass on — refresh the checkpoint or commit, - then end. The maintainer should never have to ask you to wrap up. -- For significant multi-turn work, create the central bundle early. After every - material maintainer turn or agent investigation cycle, preserve - consequential new understanding in the owning record's broad `Discovery - ledger`, update the affected semantic state, and refresh its structured - checkpoint last. The preservation trigger is consequence of information - loss, not a fixed category of findings. Small jobs noticed along the way go in - the checkpoint's own list — `--todo-add`, cleared with `--todo-drop` — which is - neither a blocker nor the next action, survives a checkpoint that says nothing - about it, and reaches the next session through the brief. Anything you intend - to "come back to" and leave only in prose is lost with the context holding it. -- Run `wfctl brief --json` before anything else in a session, unless a session - brief was already delivered as context, in which case use that one — after - checking it arrived whole. A brief grows with the number of open records and - is delivered truncated once it passes what a session accepts, as a preview and - a path to the rest. Read that path before using it. The preview is the first - bytes rather than the important ones, and a session opened on it starts from a - fraction of the state while reading exactly like a complete one. It is the - authoritative current state of this repository: signals are observed facts and - capabilities are derived from them. Do not rediscover that state by scanning - records, and do not read the list back to the maintainer. Every open record - carries a `*.resume` signal holding where its work stopped and the next action - it named; that is the resume state, so read it rather than reconstructing one. - Compose one short orientation from it — what exists, what is in progress, what waits on them — - and offer the operations reported available. For a blocked capability, name - what would unblock it instead of starting it. The brief never starts work; a - signal with `awaits: maintainer` is a question for them, not a task for you. -- On resume, compaction, or a clean-session start, run `wfctl work context - --stage resume` without an ID. Auto-select only when exactly one active record - is bound to the current checkout. If several exist, inspect `wfctl work - status` and ask the maintainer which human outcome to resume; never guess. - Read every required file and discovery entry completely, then recover from - the bundle, current checkpoint, and exact claim rather than conversation - memory. -- Use `changes/inbox/` only for pending captures that have no active or curated - owner. Resolve each capture to existing destinations or discard it with a - reason; never duplicate active progress there. The brief names every pending - capture, so an unresolved one is not unknown to you — a queue that grows - without anyone opening it is the same as no queue. A capture only the - maintainer can settle is created with `--awaits maintainer` and presented to - them as one decision at a time, not listed as a backlog. -- After `wfctl upgrade`, commit the files it names in a commit of their own - before continuing. They are tracked project files, and folding them into the - next unrelated commit hides what the upgrade changed. The new agent block and - rules reach a session only at its start, so say plainly that a restart is - needed rather than acting as if the new instructions are already loaded. -- Do not create a competing leaf-local spec or issue tracker. Claim one central - frontier issue from the exact bound checkout before implementation. Before - completion, account for every bundle file at its current hash; a receipt - proves accounting, not comprehension. -- Treat the maintainer/product and engineering roads as linked, first-class - views of the same project, never one blended document and never one derived - from the other. Product pages explain current behavior to stakeholders; - engineering pages explain implementation to engineers and operators. - Decision lineage connects both roads rather than forming a third flat view. -- Route broad project discovery, newcomer onboarding, Area exploration, and - focused product-understanding questions to `explore-project-knowledge`. - Exploration is read-only: answer progressively from curated knowledge - without requiring the user to know Areas, capability names, or file paths. -- Route product authoring to `curate-product-knowledge`, technical authoring to - `curate-engineering-knowledge`, and every material knowledge edit through - `verify-knowledge-quality` before it becomes stable. Keep authority/truth - and reader communication as separate semantic passes. - -This is a leaf repository. Its project knowledge is located at `../knowledge-humid`. - -Classify work with the installed `manage-project-work` skill before changing -implementation state. For significant work, create the central bundle first, -then invoke `analyze-with-graphify` and `align-project-knowledge` before -implementation. - -If a consequential initiative has several unresolved dependent product or -architecture choices and cannot yet support honest acceptance criteria, -recommend `shape-project-direction`. Start its Wayfinder map only after -maintainer agreement, and do not edit code until the map has been synthesized -into a reviewed bounded specification. - -For read-only questions about what the project is, what it currently provides, -or how one product direction works, invoke `explore-project-knowledge` against -the configured knowledge repository. Do not require the user to name an Area, -capability, or knowledge path. A product explanation alone does not create a -work record or authorize code changes. - -Run QMD from the configured knowledge repository for knowledge retrieval. Do -not query `raw/` or `intake/` to fill gaps in current project truth. - -After `wfctl work start`, run `wfctl work status <id>` and the stage-specific -`wfctl work context <id>` before any code edit, after changing directories, -and before verification or close. On a clean session or unspecified resume, -start with `wfctl work context --stage resume` without an ID; it may select only -one bound active record and otherwise requires a maintainer choice. -Use only the reported `Code roots` for their respective code operations and -the reported `Spec` only for record updates. Refresh the owning change or issue -checkpoint after every material edit. A worktree is a distinct -code root; never infer another checkout from repository name, branch, or spec -location. A branch/worktree mismatch requires explicit `wfctl work rebind` -before any code edit. - -Keep one canonical change bundle in the knowledge repository. `change.md` owns -the parent contract, optional `map.md` owns Wayfinder lineage, and `issues/` -owns bounded progress. After material discussion or investigation, preserve -consequential new understanding in the current owner's `Discovery ledger`, -update its semantic state, and refresh that record's checkpoint last. Claim one -frontier issue from the exact leaf before implementation. Verify the whole -bundle with `verify-project-work`, -promote durable truth separately, then archive the directory intact. - -During promotion, keep linked product and engineering views separate. Invoke -`curate-product-knowledge` for stakeholder-facing behavior, -`curate-engineering-knowledge` for technical realization, and -`verify-knowledge-quality` before a materially changed concept becomes stable. -The quality gate keeps authority/truth and reader communication as independent -passes. -<!-- wfctl:end --> diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/PROJECT_WORKFLOW.md b/PROJECT_WORKFLOW.md deleted file mode 100644 index 7e33236..0000000 --- a/PROJECT_WORKFLOW.md +++ /dev/null @@ -1,395 +0,0 @@ -<!-- wfctl:begin --> -# Project Workflow - -This is the maintainer-facing operating guide installed by `wfctl`. - -- Profile: `leaf` -- Project knowledge: `../knowledge-humid` - -The workflow is a project partnership system. It keeps the maintainer and agent -inside one shared project model from product intent to verified delivery. The -maintainer can recover and navigate that model directly; the agent uses the -same model to investigate, implement, verify, and maintain continuity. - -It provides two linked, first-class roads: - -- the **maintainer/product road** explains purpose, capabilities, behavior, - rules, delivery, and evolution in human language; -- the **engineering road** explains architecture, ownership, source - realization, contracts, operations, and verification. - -Neither road is derived from the other. Shared Areas, changes, and decision -history keep them aligned. The agent inventories and verifies evidence, -maintains records, and presents bounded decisions. The maintainer supplies -product intent, resolves authority conflicts, and approves material -commitments. - -## What the maintainer operates - -Your normal interface is conversation. Describe the outcome in project -language; the agent chooses and runs `wfctl`, QMD, Graphify, Git inspection, -and record-maintenance operations. You do not need command syntax, record IDs, -generated paths, or structured-file schemas. - -The normal optional manual CLI entry points are `wfctl init knowledge`, `wfctl -init leaf`, and `wfctl upgrade` from the repository being upgraded; the setup -agent can run them too. `--target` and other commands remain available for -agents, automation, diagnostics, recovery, and workflow contributors. You -review framing, missing authority, material choices, completion, and current -knowledge claims. - -## Installed skill lineage - -The leaf delivery flow directly reuses and modifies selected MIT-licensed -skills from `mattpocock/skills`: Wayfinder, To Spec, To Tickets, Implement, -TDD, and Code Review. `wfctl` integrates them into its own central bundle, -worktree claims, knowledge alignment, and completion gates; it does not install -the original suite or a second tracker beside them. - -Project-scope copies live under `.agents/skills/` and/or `.claude/skills/` and -are recorded by `skills-lock.json`. These are generated consumer copies: update -the canonical `wfctl` package, then run `wfctl upgrade` here instead of editing -them locally. The canonical distribution keeps one third-party provenance -record and one upstream license instead of repeating them in every skill. -User-scope locations follow the selected agent convention. - -After initialization, ask in ordinary language: “help me understand this -project,” “process raw,” “reconstruct the baseline,” “explain this Area,” -“shape this broad direction,” “research this external constraint,” “implement -this change,” or “check the workflow.” The installed skills translate those -requests into the complete internal procedure. You do not need to invoke a -skill by name. - -## Trust boundary - -| Surface | Purpose | Trust | -| --- | --- | --- | -| `raw/` | Continuous append-only dumps and source material | Untrusted clue source; never evidence | -| `intake/` | Git-frozen raw review cases | Operational audit trail; never cited by knowledge | -| `reconstruction/` | Source-first project baselines and audits | Qualified review records; opt-in, not default truth | -| `changes/active/` | One change bundle per significant outcome: parent contract, structured checkpoints, optional map, bounded issues, artifacts, and review ledger | Current execution agreement | -| `changes/archive/` | Closed change bundles plus resolved capture receipts | Historical record qualified by outcome and reviews | -| `changes/inbox/` | Pending captures with no active or curated owner yet | Non-authoritative queue awaiting explicit routing or discard | -| `knowledge/` | Curated OKF concepts | Default current project knowledge | -| source repositories | Executable implementation | Implementation authority at an exact revision | - -Raw text can tell the agent what to investigate. It cannot support a claim, -even when several raw files agree. A trusted derivative must cite the -maintainer decision, pinned code, runtime receipt, closed change, or -primary external source that independently established the claim. - -## Multiple inputs, one promotion gate - -Raw dumps, source reconstruction, and ongoing work stay separate until -verification: - -1. A bounded `raw/` scope is frozen to exact Git blobs in `intake/`. - QMD helps locate relationships; the agent then reads every frozen file and - extracts candidate claims. -2. A bounded reconstruction binds exact clean leaf revisions, uses Graphify - plus direct source, creates repository dossiers, and separates observed - implementation from accepted intent. -3. Significant ongoing work produces a central bundle under `changes/active/` - with stable acceptance, bounded issue progress, complete file accounting, - and fresh implementation receipts. -4. Every lane verifies each claim against its proper authority. -5. The maintainer adjudicates intent, normative decisions, and unresolved - conflicts. -6. Only then does the agent update `knowledge/` and run the strict validator. - -Unresolved raw candidates remain in intake. `knowledge/uncertainties/` is only -for live questions supported by trusted current evidence. - -## Graphify boundary - -Graphify is mandatory for source-code navigation and relationship analysis. -The routing skill checks that the official native `graphify` skill is active, -invokes any more specific Graphify skills, and stops code work if they are -missing. The agent then directly inspects the actual source and checks at the -bound Git revision; Graphify output itself is not authority. - -Graphify is not the analyzer for Markdown, raw intake, or OKF concepts. QMD -provides BM25, semantic, and hybrid retrieval for those surfaces; direct file -reading, Git coverage, provenance, and validation remain authoritative. - -## Compiled knowledge and claim graphs - -`wfctl knowledge build` validates `knowledge/` and compiles only authored -Markdown links, typed `x-wf.relations`, Area ownership, and decision lineage -into `.workflow/current/knowledge-graph.json`. The ignored file is a -rebuildable navigation artifact: it adds no inferred truth and is never edited -or cited as authority. - -The same build writes `.workflow/current/claim-ledger.json` from intake and -reconstruction cases. It contains atomic claim states and only explicit -supersession, contradiction, refinement, implementation, and derivation -relations. It helps trace adjudication and chronology, but never decides truth -or compensates for a relation the agent failed to record. - -QMD discovers candidate documents by meaning. The compiled graph expands those -candidates through explicit reviewed relationships. The claim ledger traces -operational lineage. Graphify handles source code. In every case, the agent -reads the selected source documents directly. - -## QMD retrieval boundary - -`wfctl` installs a project-local `.qmd/index.yml` in the knowledge repository. -Its collections are intentionally separated: - -- `knowledge` is the only default search surface; -- `changes`, `intake`, `reconstruction`, and `raw` require explicit collection - selection. - -The QMD index is disposable. Search rank and snippets help navigation but prove -neither corpus coverage nor truth. The agent runs QMD from the knowledge root, -updates the index after content changes, and reads selected files directly. -The official native `qmd` skill must be active in the current agent session; -installation on disk requires a session restart before it counts. - -`wfctl check` distinguishes two readiness levels: - -- BM25 lexical readiness is required and depends on a healthy project-local - index refreshed by `qmd update`; -- semantic/hybrid readiness depends on local models and current embeddings. - -Missing models or embeddings are warnings while BM25 remains healthy. Model -download and embedding work happen only when semantic retrieval is needed. - -## OKF and the stricter workflow profile - -`knowledge/` follows -[Open Knowledge Format v0.2](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md). -OKF is a portable Markdown format, not an approval workflow. This project adds -a stricter profile: - -- explicit `status`, `generated`, provenance, and current verification; -- explicit `view`, `purpose`, and `audience` so product and engineering - knowledge cannot silently collapse into one document; -- explicit authority classes so deterministic validation can distinguish - normative, implementation, historical, and external claims; -- explicit intent, delivery, and alignment state for product-bearing concepts; -- claim-level source IDs joined to Markdown footnotes; -- pinned repository revision and path for code sources; -- human verification for intent and normative decisions; -- explicit supersession or deprecation reason; -- reciprocal acyclic decision lineages with one stable current record; -- a semantic quality receipt with independent authority/truth and - reader-communication axes bound to the current content hash; -- stakeholder sections and a no-code boundary for product documents; -- technical coverage and linked product meaning for engineering documents; -- no raw path, source, link, or footnote in current knowledge. - -`stable` is lifecycle, not automatic truth. A material edit updates -`generated.at` and changes the deterministic knowledge content hash. Older -verification and quality receipts no longer match. The agent runs the semantic -quality reviews and records fresh axis plus verification receipts only after computing -`wfctl knowledge hash --concept <path>`. - -## Choose the work route - -Use the full workflow when work may change observable behavior, domain meaning, -interfaces, schemas, protocols, data or control flow, persistent state, -security, reliability, operations, architecture, ownership, or coordination -across components or repositories. - -Use the lightweight path only when behavior and contracts clearly remain -unchanged. Size is not the classifier. When ambiguous, the agent explains the -impact, recommends a route, and asks you. A pending capture may preserve a -useful lightweight result without imposing the full gate, but only when no -active change or curated concept already owns it. - -Accepted unowned material enters `changes/inbox/` through `wfctl work capture -add`. Leaf captures retain exact source/worktree metadata; knowledge captures -retain project-only intake/reconstruction proposals and their claim references. -The knowledge agent lists and reads pending captures, creates real destinations, -then resolves each as routed or discarded. Resolved receipts move to -`changes/archive/captures/`; they never become authority by themselves. - -Active work never uses the inbox for session state. The owning change or issue -contains one structured checkpoint. The agent refreshes it after material -edits, and `wfctl work context` rejects a stale hash while still requiring the -underlying files to be read completely. - -Read-only explanation, history, ownership, and health questions run -automatically. Raw processing, whole-project reconstruction, durable external -research, semantic curation, and broad direction shaping are deliberate: -unless you already requested the outcome, the agent explains the gap and asks -one focused confirmation before starting. - -For a consequential initiative whose route is too unclear for one honest -specification session, the agent may recommend deliberate Wayfinder. One map -stores the destination, standing context, fog, and named resolution pointers; -precise questions become dependency-aware issues. When the route clears, the -agent reads every issue, synthesizes the ordinary change specification, and -only then starts delivery. It never builds the destination from an unresolved -map or creates a parallel strategy source. - -## Review gates - -1. **Routing** — only when significance is ambiguous. -2. **Framing** — outcome, scope, exclusions, acceptance criteria, and new - decisions before significant implementation. Clear existing instructions - can satisfy this; material re-scoping reopens it. -3. **Authority** — whenever evidence cannot establish current intent, - chronology, or which source governs. -4. **Knowledge** — material claims about vision, product meaning, architecture, - ownership, contracts, policy, decisions, supersession, or accepted risk. -5. **Promotion** — the pages the work would write into `knowledge/`, in full, - and what each of them replaces. - -You are not asked whether finished work is finished. Whether the criteria are -met, the receipts carry evidence and the revisions are pinned is what the tool -checks, and it checks it better than a summary of it can. The exception is -delivery that no longer matches the framing you approved — reworded criteria, or -work dropped from the route — and there the agent comes back to you. - -An approval is explicit. Silence and continued conversation are not approval. -You never edit YAML manually. - -Approvals are recorded by you: `wfctl work approve <change-id> --stage framing ---by human:<your-id>` for a framing, and `wfctl work promote <change-id> --by -human:<your-id>` for the pages, which writes them in the same act. Ordinarily the -agent records the answer you already gave in conversation, word for word. You can -ask instead for a typed confirmation, or for automation to use a `--token` -matching a `WFCTL_APPROVAL_TOKEN` you set out of band; a receipt written into the -record by hand fails verification either way. - -This records provenance, not identity. It shows the approval came from a -deliberate separate step, not that a specific person typed it. Every other -review decision the agent records as a stable `human:<reviewer-id>` and -timestamp after you answer. - -## Review packet - -Each request should contain: - -1. **Decision** — the exact claim, framing, or outcome. -2. **Evidence** — pinned sources and fresh verification. -3. **Conflicts** — contrary evidence, gaps, deviations, or risk. -4. **Recommendation** — the agent's preferred answer and reasoning. -5. **Requested response** — approve, correct, or defer. - -Deferral is valid. The agent preserves uncertainty instead of guessing. -When several decisions are needed, the agent asks one at a time and updates -the living record before continuing. - -## Significant-work loop - -1. Classify the task. -2. Immediately create and bind a central bundle with `wfctl work start`. -3. Use `wfctl work status` to distinguish every exact implementation `Code - root` from the central bundle. Use stage-specific `wfctl work context` to - enumerate every file the agent must read. Project-only work has no code root. -4. Record the current request, constraints, open questions, and next action. -5. Analyze source code through Graphify and direct inspection in every bound - repository; skip this only when the record has no code scope. -6. Align with current `knowledge/`. -7. Resolve blocking authority questions and obtain framing approval. -8. Set the change active. For multi-session work, create dependency-aware - issues whose acceptance coverage and repository scope are explicit. -9. Read and claim one frontier issue from the exact bound leaf. Implement only - there while keeping issue progress and the parent contract current. -10. Reconcile every stable criterion against the actual implementation. -11. With normal maintainer authorization, preserve the implementation - in the bound Git commit; `wfctl` never commits automatically. -12. Run final checks against every clean commit and record one revision and - worktree receipt per repository. -13. Enumerate and read the complete bundle, refresh every content-hash receipt, - and reject unseen, changed, malformed, or silently dropped work. -14. Draft the pages this work changes under the bundle's `promotion/` directory, - at the path each will occupy inside `knowledge/`, and record them with - `wfctl work promotion <change-id>` — or `--none "<why>"` when nothing the - project says about itself changed. -15. Run `wfctl knowledge build --target <Knowledge root>` to prove links, - authored relationships, and stable-concept reachability. -16. Run `wfctl work verify`, and archive the honest outcome with - `wfctl work close`. A bundle holding pages waits in the promotion queue - rather than the archive. -17. Put the pages to you with `wfctl work ask <change-id> --stage promotion`, and - write them on your word with `wfctl work promote <change-id>`. That copies - them into `knowledge/`, validates them, and archives the bundle. - -A material turn may come from discussion or investigation. It changes a -requirement, constraint, alternative, decision, scope, evidence, risk, -question, next action, or consequential understanding. The agent preserves new -information in the owning `Discovery ledger` when losing it could cause -repeated material investigation, a different choice, misunderstanding, or -unsafe action; then it updates mutable state and any decision lineage. After -interruption, compaction, or a clean-session start, it runs `wfctl work context ---stage resume` without an ID, accepts automatic selection only for one bound -record, reads every required bundle file completely, and resumes from recorded -state instead of chat memory. - -Partial or abandoned outcomes are valid historical records. They must never be -relabeled as completed. A completed close also requires a clean bound checkout, -so the archived revision actually contains the verified implementation; the -workflow never commits automatically. - -## Routine health - -Ask the agent to “check the workflow environment” or “upgrade the workflow.” -It runs diagnostics or previews the upgrade, explains conflicts in human -terms, and requests only the decisions needed. Generated assets with local -edits become explicit conflicts and are never silently overwritten. - -## Leaf repository practice - -The curated project knowledge for this repository is at -`../knowledge-humid`. - -At the start of significant work, the agent first creates a central bundle so -the discussion cannot disappear after compaction. It then performs Graphify -code analysis and QMD-assisted current-knowledge alignment before presenting -the framing packet. QMD runs from the configured knowledge root and searches -only its `knowledge` collection by default. At the end, you are shown the pages -the work would write into project knowledge, in full, and nothing enters until -you say so. - -Describe the desired change in ordinary language. The agent owns creation, -status checks, verification, closure, and archival of the work record; you never -need its ID or commands. It asks you only for ambiguous routing, framing, -product authority, commit authorization, and what the project now says about -itself. - -You may also ask read-only product questions here: - -> I am new to this project. What is it for and what can it do today? - -The agent reads the configured knowledge repository and progressively explains -the product without creating a work record or changing this checkout. - -The canonical bundle remains under `changes/active/<change-id>/` in the -knowledge repository. `change.md` holds the parent contract, optional `map.md` -holds Wayfinder lineage, and `issues/` holds bounded work. Each active owner has -one structured checkpoint. This leaf stores -only ignored binding and claim pointers in `.workflow/current/`. - -`wfctl work status` reports intentionally different paths: - -- `Code roots`: one or more exact leaf checkouts or linked worktrees where - their respective implementation may be read and modified. -- `Spec`: the parent `change.md`; stage-specific `wfctl work context` lists - every additional map, issue, blocker, or artifact the agent must read. - -The agent must never infer another checkout from repository name, branch, Git -common directory, or spec location. A worktree or branch mismatch blocks -verification and close until an explicit `wfctl work rebind`. - -During discussion or investigation, every material change and every newly -learned fact whose loss could cause repeated work, a different decision, -misunderstanding, or unsafe action is written to the owning bundle record. The -record's `Discovery ledger` accepts any consequential observation with its -basis, implication, scope, and destination; it is not restricted to named -categories. The checkpoint is refreshed last. On resume, the agent discovers -the one active binding when unambiguous, inspects its checkpoint, and reads -every file and discovery entry listed by the current context rather than -relying on remembered chat. - -When the completed change updates durable knowledge, review two separate -results when both changed: the stakeholder-facing product behavior and the -engineering realization. The product view should be understandable without -code; the engineering view should pin the actual implementation. A semantic -quality receipt with independent authority/truth and reader-communication -passes, plus strict structural validation, must pass before either is called -stable. -<!-- wfctl:end --> diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts index 4a5c2c9..a91e921 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts @@ -1,9 +1,10 @@ import type { LiquidWalletBackend } from "../../application/backends/LiquidWalletBackend"; import { getWalletActivityForAsset } from "./wallet/getActivity"; import { getWalletBalanceForAsset } from "./wallet/getBalance"; -import { getWalletReceiveAddress } from "./wallet/getReceiveAddress"; +import { getWalletReceiveAddress, getWalletSigningAddress } from "./wallet/getReceiveAddress"; import { getExplicitWalletUtxosForAsset, getWalletUtxosForAsset } from "./wallet/getUTXOs"; import { getWalletDescriptorEntries } from "./wallet/getWalletDescriptor"; +import { readChainTipHeight } from "./wallet/readChainTipHeight"; import { createLwkLiquidAccount } from "./wallet/resolveAccount"; import { estimateMaxSend, inspectTransfer, sendTransfer } from "./wallet/sendTransfer"; import { inspectMessageSigning, signMessage } from "./wallet/signMessage"; @@ -16,8 +17,10 @@ export function createLwkWalletBackend(): LiquidWalletBackend { getActivity: getWalletActivityForAsset, getBalance: getWalletBalanceForAsset, getReceiveAddress: getWalletReceiveAddress, + getSigningAddress: getWalletSigningAddress, getDescriptorEntries: getWalletDescriptorEntries, getExplicitUtxos: getExplicitWalletUtxosForAsset, + getTipHeight: readChainTipHeight, getUtxos: getWalletUtxosForAsset, inspectMessageSigning, inspectTransfer, diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getReceiveAddress.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getReceiveAddress.ts index 20e9bcb..75e23e8 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getReceiveAddress.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getReceiveAddress.ts @@ -1,6 +1,9 @@ import type { LiquidWalletAccount } from "../../../application/backends/LiquidWalletBackend"; import { getLwkImplementation } from "./getLwkImplementation"; +/** The one index the contract path signs at, as `readExplicitWalletUtxos` states. */ +const SIGNING_ADDRESS_INDEX = 0; + /** * The wallet's current receive address — the last unused address (index 0 for a * fresh, unsynced wallet). Deriving it needs no network sync. @@ -14,3 +17,20 @@ export function getWalletReceiveAddress(account: LiquidWalletAccount): { return { address: result.address().toString(), index: result.index() }; } + +/** + * The address a contract action can spend from: the account's first external address. + * + * Fixed rather than rotating, and deliberately so — `readExplicitWalletUtxos` accepts only + * outputs at this index, because the signing module derives one key there. Handing a protocol's + * token back to a rotating address makes it unspendable by the same path that received it. + */ +export function getWalletSigningAddress(account: LiquidWalletAccount): { + address: string; + index: number; +} { + const implementation = getLwkImplementation(account); + const result = implementation.wollet.address(SIGNING_ADDRESS_INDEX); + + return { address: result.address().toString(), index: result.index() }; +} diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readChainTipHeight.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readChainTipHeight.ts new file mode 100644 index 0000000..43be5ca --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readChainTipHeight.ts @@ -0,0 +1,35 @@ +import { + WALLET_RPC_ERROR_REASONS, + WalletRpcResourceUnavailableError, +} from "@/core/wallet-rpc/errors"; + +import type { LiquidWalletAccount } from "../../../application/backends/LiquidWalletBackend"; +import { getLwkImplementation } from "./getLwkImplementation"; + +/** + * How high the chain is, as this wallet already knows it. + * + * Read from the scan rather than from an endpoint. The wallet syncs its descriptor against + * whichever backend a chain is configured with, and the tip is what that scan reached — so it + * costs no network call and is available wherever the wallet works. A plain Esplora route for + * the same fact is not universal: the Waterfalls server this wallet uses for Liquid testnet + * serves the descriptor scan and answers 404 to `/blocks/tip/height`, which is exactly how a + * transaction that should have declared a locktime came to declare zero. + * + * Accurate as of the last sync, which for a contract action is moments earlier: the method + * syncs the account before it reviews anything. + */ +export function readChainTipHeight(account: LiquidWalletAccount): number { + const implementation = getLwkImplementation(account); + const tip = implementation.wollet.tip(); + + try { + return tip.height(); + } catch { + throw new WalletRpcResourceUnavailableError( + "Could not read the chain tip from the LWK wallet state.", + undefined, + WALLET_RPC_ERROR_REASONS.WALLET_UTXO_READ_FAILED, + ); + } +} diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/assetOrder.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/assetOrder.test.ts new file mode 100644 index 0000000..3b87c02 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assetOrder.test.ts @@ -0,0 +1,111 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * Which way round an asset id goes into a covenant, settled by running one. + * + * An asset id is written one way and committed the other, the same way a transaction id is. + * Everything on this side of the wallet uses the written order — the chain reader turns each + * one round on the way in, a document states one that way, a person reads one that way — and a + * covenant compares against what `jet::input_amount` reports, which is the committed order. + * + * Getting this wrong is not an error anywhere. Both orders are thirty-two valid bytes, so both + * compile, and both produce a real address that a wallet would then compare against the chain + * and refuse — or, on the paying side, pay to. So it is not decided by reading: the contract is + * built both ways here and executed against a transaction carrying the asset, and only one of + * them runs. + * + * `asset_auth.simf` is the corpus's smallest contract that takes an asset id. Every asset-id + * parameter in the corpus is used the same way it uses this one — compared against what a jet + * reports about an input or an output — in `asset_auth_vault.simf` and `lending.simf` too. + */ + +/** An asset id as everything states one, chosen so that turning it round changes it. */ +const STATED = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec8ef5b4d5"; +const TXID = "2".repeat(64); +const AMOUNT = 7n; + +/** The witness names the indices the contract checks: input 0 and output 0. */ +const WITNESSES = JSON.stringify({ + INPUT_ASSET_INDEX: { type: "u32", value: "0" }, + OUTPUT_ASSET_INDEX: { type: "u32", value: "0" }, +}); + +let source = ""; + +beforeAll(async () => { + source = await contractSource("asset_auth.simf"); +}); + +function turnRound(hex: string): string { + return (hex.match(/../g) ?? []).toReversed().join(""); +} + +function argumentsWith(assetHex: string): string { + return JSON.stringify({ + ASSET_AMOUNT: { type: "u64", value: String(AMOUNT) }, + ASSET_ID: { type: "u256", value: `0x${assetHex}` }, + WITH_ASSET_BURN: { type: "bool", value: "false" }, + }); +} + +/** + * The covenant's own output, serialised the way a transaction carries one. + * + * The asset is written committed-order here because that is what a transaction holds; the + * builder is separately given the stated order for the output it makes, so the two ends of the + * check are constructed independently and can only agree by being right. + */ +function txOut(scriptPubKeyHex: string): string { + const value = AMOUNT.toString(16).padStart(16, "0"); + const length = (scriptPubKeyHex.length / 2).toString(16).padStart(2, "0"); + + return `01${turnRound(STATED)}01${value}00${length}${scriptPubKeyHex}`; +} + +/** Builds the covenant with the given asset bytes and runs it against a transaction. */ +function outcomeOf(assetHex: string): { address: string; ran: boolean } { + const argumentsJson = argumentsWith(assetHex); + const contract = new smplx.Contract(source, argumentsJson, "[]", false); + const scriptPubKeyHex = contract.scriptPubKeyHex("liquid-testnet"); + const address = contract.contractAddress("liquid-testnet"); + const builder = new smplx.TransactionBuilder(); + + try { + builder.addContractInput(TXID, 0, txOut(scriptPubKeyHex), source, argumentsJson, WITNESSES); + builder.addOutput(scriptPubKeyHex, AMOUNT, STATED); + builder.dryRunContractInput(0, "liquid-testnet"); + + return { address, ran: true }; + } catch { + return { address, ran: false }; + } finally { + builder.free(); + contract.free(); + } +} + +describe("an asset id compiled into a covenant", () => { + test("executes when it is the committed order", () => { + expect(outcomeOf(turnRound(STATED)).ran).toBe(true); + }); + + test("and does not when it is the order the document states it in", () => { + expect(outcomeOf(STATED).ran).toBe(false); + }); + + /** + * The reason this is decided by running rather than by reading: the wrong one is not a + * failure to compile or a failure to derive. It is a different covenant, at a real address, + * that nothing reports until money is already there. + */ + test("and the wrong order still produces a perfectly good address", () => { + const wrong = outcomeOf(STATED); + const right = outcomeOf(turnRound(STATED)); + + expect(wrong.address).toMatch(/^tex1p/); + expect(right.address).toMatch(/^tex1p/); + expect(wrong.address).not.toBe(right.address); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/blindedOutputs.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/blindedOutputs.test.ts new file mode 100644 index 0000000..c2ed444 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/blindedOutputs.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test"; + +import { guardBlindedOutputs, txOutAt } from "@humid/tx-manifest"; + +import { smplx as bindings } from "./smplxWasmForTests"; + +/** + * What a blinding key actually does to a transaction, established by building one. + * + * The wallet decides whether an output hides what it carries while it reads the document, and + * the module that builds the transaction has never read it. All that crosses between them is a + * blinding key or the absence of one, and the call says nothing about what became of it — so + * until this file, the whole seam rested on a comment. Every other test that finalises a + * transaction here builds every output in the open, which means the blinding path had never + * run anywhere in this repository while the published protocols hide amounts in it. + * + * So both halves are measured here rather than assumed: that a key handed over produces a + * commitment where the amount would be, and that the guard reading those bytes afterwards + * tells the two apart. The second half is the one that matters when the first stops being + * true, which is why the wrong build is exercised alongside the right one. + */ + +// A BIP39 test vector, not a wallet mnemonic. +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const TXID = "7".repeat(64); +// L-BTC on Liquid testnet, the policy asset the fee is paid in. +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const FEE_RATE = 100; + +/** An explicit output of `sats` of the policy asset — the only kind a contract action spends. */ +function encodeTxOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const scriptLen = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${scriptLen}${scriptHex}`; +} + +/** + * One transaction built exactly the way the method builds one, and signed. + * + * `paymentBlinded` and `changeBlinded` are what the wallet decided; passing them through as a + * blinding key or as nothing is the same line the method runs. + */ +function build(paymentBlinded: boolean, changeBlinded: boolean): string { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + + try { + const script = signer.scriptPubKeyHex(); + + builder.addWalletInput(TXID, 0, encodeTxOut(100_000n, script)); + builder.addOutput( + script, + 50_000n, + POLICY_ASSET, + paymentBlinded ? signer.blindingPublicKey() : undefined, + ); + builder.addChange(script, changeBlinded ? signer.blindingPublicKey() : undefined); + + const signed = signer.finalizeTransaction(builder, FEE_RATE); + const hex = signed.hex; + + signed.free(); + + return hex; + } finally { + builder.free(); + signer.free(); + } +} + +/** Whether the output at `vout` came back with an amount anyone can read. */ +function published(transactionHex: string, vout: number): boolean { + const found = txOutAt(transactionHex, vout); + + if (!found.ok) { + throw new Error(found.reason); + } + + return found.txOut.amountSats !== undefined; +} + +describe("an output built with a blinding key", () => { + test("comes back with its amount and its asset committed rather than written", () => { + const built = build(true, true); + + expect(published(built, 0)).toBe(false); + expect(published(built, 1)).toBe(false); + // The fee is the one output the network has to read, and it stays in the open. + expect(published(built, 2)).toBe(true); + }); + + // The same builder, the same inputs, one argument dropped. Nothing about the call fails + // and the transaction is perfectly valid; the amount is simply on the chain. + test("and comes back written when the key is not passed", () => { + const built = build(false, false); + + expect(published(built, 0)).toBe(true); + expect(published(built, 1)).toBe(true); + }); + + // Each output answers for itself. A transaction is not blinded or unblinded as a whole, + // which is what makes a per-output decision meaningful at all. + test("independently of what the other outputs did", () => { + const built = build(true, false); + + expect(published(built, 0)).toBe(false); + expect(published(built, 1)).toBe(true); + }); +}); + +describe("the guard against what was actually built", () => { + const hides = { changeBlinded: true, outputs: [{ blinded: true, id: "principal_claimed" }] }; + + test("passes a transaction that hides exactly what the wallet decided to hide", () => { + expect(guardBlindedOutputs(build(true, true), hides)).toEqual({ ok: true }); + }); + + // What a dropped decision looks like from the far side: the wallet decided to hide, the + // transaction published, and nothing between the two said so. + test("refuses the same transaction built without the key", () => { + const result = guardBlindedOutputs(build(false, false), hides); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("publishes the amount on principal_claimed"); + }); + + test("refuses a hidden amount where the wallet decided on an open one", () => { + const result = guardBlindedOutputs(build(true, true), { + changeBlinded: false, + outputs: [{ blinded: false, id: "vault_out" }], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("hides the amount on vault_out"); + }); + + // The change is not one of the outputs the wallet adds, so it is the one the guard finds + // by position rather than by name — and getting that wrong would pass everything. + test("refuses a published change where the wallet decided to hide it", () => { + const result = guardBlindedOutputs(build(true, false), hides); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("publishes the amount on the change"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/covenantLeaves.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/covenantLeaves.test.ts new file mode 100644 index 0000000..d023c97 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/covenantLeaves.test.ts @@ -0,0 +1,128 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * The compiler end of a covenant's extra taproot leaves. + * + * `tx-manifest` proves that reading a live lending protocol's published document produces exactly + * the two leaf payloads below, from a flag it writes as a literal and a debt it writes as a typed + * value. It holds no compiler, by design. This is the half that cannot be asserted there: that + * those bytes reach a real compiler, build a real covenant, and that every way of getting them + * wrong builds a different one. + * + * **Why a leaf cannot be guessed.** The compiler puts each payload in a storage slot and adds it + * to the taproot tree as a hidden node hashed `sha256(tag ‖ tag ‖ payload)`, `tag = + * sha256("TapData")`. A hidden node has no script to fail on and no witness to check: a payload + * that is wrong in any byte, or in its order, produces a perfectly valid address for a covenant + * nobody deployed. Nothing anywhere reports it. The wallet then compares that address against the + * one holding the funds, finds a difference, and refuses an action that was legitimate — for a + * reason nothing on screen can explain. So the bytes are read out of the document and checked + * against the protocol's own two implementations of them, never inferred. + * + * **Nothing here was compared against a chain.** The published document records a deployed + * scriptPubKey for its factory, which carries no extra leaves, and none for the collateral + * covenant, which carries these two. So these addresses are reproducible rather than confirmed. + * Confirming one needs a deployed offer whose script is readable from Liquid. + */ + +const ASSET = (byte: string) => `0x${byte.repeat(32)}`; + +/** + * The compile parameters `tx-manifest`'s review builds for the active collateral covenant, from + * this deployment's fields. Kept character for character as that review hands them over. + */ +const ACTIVE_COLLATERAL = JSON.stringify({ + BORROWER_NFT_ASSET_ID: { type: "u256", value: ASSET("b1") }, + COLLATERAL_AMOUNT: { type: "u64", value: "100000" }, + COLLATERAL_ASSET_ID: { type: "u256", value: ASSET("c1") }, + FINALIZED_LENDER_VAULT_COV_HASH: { type: "u256", value: ASSET("11") }, + FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: ASSET("33") }, + LENDER_NFT_ASSET_ID: { type: "u256", value: ASSET("d1") }, + LENDER_VAULT_COV_HASH: { type: "u256", value: ASSET("22") }, + LOAN_EXPIRATION_TIME: { type: "u32", value: "1900000000" }, + PRINCIPAL_AMOUNT: { type: "u64", value: "50000" }, + PRINCIPAL_ASSET_ID: { type: "u256", value: ASSET("a1") }, + PRINCIPAL_INTEREST_RATE: { type: "u64", value: "500" }, + PRINCIPAL_OUTPUT_SCRIPT_HASH: { type: "u256", value: ASSET("55") }, + PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: ASSET("44") }, +}); + +/** The offer is active: the flag slot is thirty-one zero bytes and a one. */ +const ACTIVE = `${"00".repeat(31)}01`; + +/** The debt slot: 52500 as eight big-endian bytes, right-aligned in thirty-two. */ +const DEBT = `${"00".repeat(30)}cd14`; + +/** The document says its contracts were built with debug symbols, and that changes the address. */ +const DEBUG_SYMBOLS = true; + +let lending = ""; + +beforeAll(async () => { + lending = await contractSource("lending.simf"); +}); + +function scriptPubKeyFor(leaves: string[]): string { + const contract = new smplx.Contract( + lending, + ACTIVE_COLLATERAL, + JSON.stringify(leaves), + DEBUG_SYMBOLS, + ); + const script = contract.scriptPubKeyHex("liquid"); + + contract.free(); + + return script; +} + +describe("the leaves tx-manifest encodes, through the compiler that builds the address", () => { + test("build a covenant", () => { + expect(scriptPubKeyFor([ACTIVE, DEBT])).toMatch(/^5120[0-9a-f]{64}$/); + }); + + test("and the compiler takes them as hex, prefixed or not", () => { + expect(scriptPubKeyFor([`0x${ACTIVE}`, `0x${DEBT}`])).toBe(scriptPubKeyFor([ACTIVE, DEBT])); + }); + + test("a payload that is not hex is refused rather than hashed as something", () => { + expect(() => scriptPubKeyFor([ACTIVE, "not-hex"])).toThrow(); + }); +}); + +/** + * Every way of getting the two leaves wrong, and what each one costs. + * + * All of these compile. None of them fails anywhere. Each is a different covenant, which is the + * whole argument for reading the bytes rather than inferring them. + */ +describe("the covenants a wrong leaf would have built instead", () => { + const right = () => scriptPubKeyFor([ACTIVE, DEBT]); + + test("dropping the leaves entirely is a different covenant", () => { + expect(scriptPubKeyFor([])).not.toBe(right()); + }); + + test("declaration order is part of the address, so swapping the two changes it", () => { + expect(scriptPubKeyFor([DEBT, ACTIVE])).not.toBe(right()); + }); + + test("the flag is one bit of one byte, and the pending offer is a different covenant", () => { + expect(scriptPubKeyFor([`${"00".repeat(32)}`, DEBT])).not.toBe(right()); + }); + + test("one satoshi of debt is a different covenant", () => { + expect(scriptPubKeyFor([ACTIVE, `${"00".repeat(30)}cd15`])).not.toBe(right()); + }); + + /** The same number, written the way this format's other byte vocabulary would write it. */ + test("and the debt written little-endian is a different covenant again", () => { + expect(scriptPubKeyFor([ACTIVE, `14cd${"00".repeat(30)}`])).not.toBe(right()); + }); + + /** Left alignment instead of right: the same eight bytes, at the other end of the slot. */ + test("as is the debt padded at the wrong end", () => { + expect(scriptPubKeyFor([ACTIVE, `000000000000cd14${"00".repeat(24)}`])).not.toBe(right()); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/createdDeployment.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/createdDeployment.test.ts new file mode 100644 index 0000000..3e9b272 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/createdDeployment.test.ts @@ -0,0 +1,136 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * The join between a deployment a wallet records and the covenant it goes on to create. + * + * A constructor works out a field that is a covenant's script hash, and the same document + * declares that covenant as a utxo type the action pays into. Those two are the same contract + * compiled twice — once to a hash the deployment stores, once to an address the transaction + * pays to — and if they ever disagree the protocol's own program will reject the spend, long + * afterwards, for a reason nothing on a confirmation screen could have shown. + * + * `tx-manifest` proves the wallet computes both from one reading of the document. This proves + * that the real compiler, given exactly what the wallet emits, makes them equal. Three things + * decide it and each one is checked below on its own, because getting any of them wrong + * produces a perfectly valid hash of the wrong covenant and nothing fails: + * + * - the extra taproot leaves, which are part of the tree the scriptPubKey is derived from; + * - the build mode the document declares, which changes the script outright; + * - the arguments, encoded at the types the document states beside them. + * + * The values below are the ones `tx-manifest`'s own tests produce for the published lending + * document at a principal of 50000 and a rate of 500 basis points, written out here character + * for character so a change at either end breaks one of the two files. + */ + +/** + * What the wallet emits for the covenant this action creates. + * + * The two leaves are the protocol's own storage slots: an all-zero state marker, and the debt + * as a big-endian u64 right-aligned in thirty-two bytes. 52500 is 50000 plus 5% of it, which is + * the value the document's own formula computes and the contract's `get_total_amount_to_repay` + * arrives at independently. + */ +const EXTRA_LEAVES = JSON.stringify([ + "0000000000000000000000000000000000000000000000000000000000000000", + "000000000000000000000000000000000000000000000000000000000000cd14", +]); + +/** 52500, big-endian, in the low eight bytes of the second leaf. */ +const DEBT = 0xcd_14; + +/** The document says its contracts were built with debug symbols, and that changes the script. */ +const DEBUG_SYMBOLS = true; + +let lending = ""; + +beforeAll(async () => { + lending = await contractSource("lending.simf"); +}); + +function scriptPubKeyOf( + argumentsJson: string, + extraLeavesJson: string, + includeDebugSymbols = DEBUG_SYMBOLS, +): string { + const contract = new smplx.Contract(lending, argumentsJson, extraLeavesJson, includeDebugSymbols); + + try { + return contract.scriptPubKeyHex("liquid"); + } finally { + contract.free(); + } +} + +const hashOf = (scriptPubKeyHex: string) => bytesToHex(sha256(hexToBytes(scriptPubKeyHex))); + +const asset = (byte: string) => byte.repeat(32); +const reversed = (id: string) => (id.match(/../g) ?? []).toReversed().join(""); + +/** + * The arguments the wallet emits for this covenant, at the types the document declares. + * + * Asset ids are reversed because that is how the chain commits them and how the jets that read + * them report them; the widths are the ones stated beside each value. Both are `tx-manifest`'s + * decisions and both are what make this a hash of the right contract. + */ +const ARGUMENTS = JSON.stringify({ + BORROWER_NFT_ASSET_ID: { type: "u256", value: `0x${reversed(asset("b1"))}` }, + COLLATERAL_AMOUNT: { type: "u64", value: "100000" }, + COLLATERAL_ASSET_ID: { type: "u256", value: `0x${reversed(asset("c1"))}` }, + FINALIZED_LENDER_VAULT_COV_HASH: { type: "u256", value: `0x${"11".repeat(32)}` }, + FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: `0x${"33".repeat(32)}` }, + LENDER_NFT_ASSET_ID: { type: "u256", value: `0x${reversed(asset("d1"))}` }, + LENDER_VAULT_COV_HASH: { type: "u256", value: `0x${"22".repeat(32)}` }, + LOAN_EXPIRATION_TIME: { type: "u32", value: "1900000000" }, + PRINCIPAL_AMOUNT: { type: "u64", value: "50000" }, + PRINCIPAL_ASSET_ID: { type: "u256", value: `0x${reversed(asset("a1"))}` }, + PRINCIPAL_INTEREST_RATE: { type: "u64", value: "500" }, + PRINCIPAL_OUTPUT_SCRIPT_HASH: { type: "u256", value: `0x${"55".repeat(32)}` }, + PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: `0x${"44".repeat(32)}` }, +}); + +describe("a covenant hash a deployment stores", () => { + test("is the hash of the scriptPubKey the same contract compiles to", () => { + const script = scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES); + + expect(hashOf(script)).toBe(hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES))); + expect(hashOf(script)).toHaveLength(64); + }); + + // Dropping the leaves is the failure this whole seam exists to prevent. A hidden taproot + // node has no script to fail on, so the wrong hash is not an error anywhere — it is a + // covenant nobody deployed, and the funds would be locked by a different one. + test("changes when the extra leaves are dropped", () => { + expect(hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES))).not.toBe( + hashOf(scriptPubKeyOf(ARGUMENTS, "[]")), + ); + }); + + // The debt is one of those leaves, so the value a document computes for itself reaches the + // address. A wallet that rounded it differently would derive a different covenant. + test("changes when the computed debt in a leaf changes by one", () => { + const other = JSON.stringify([ + "0000000000000000000000000000000000000000000000000000000000000000", + `${"0".repeat(60)}${(DEBT + 1).toString(16).padStart(4, "0")}`, + ]); + + expect(hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES))).not.toBe( + hashOf(scriptPubKeyOf(ARGUMENTS, other)), + ); + }); + + // The mode is not a refinement of an address; it is part of one. The wallet binds it from + // the document for the hashes a manifest computes and for the covenants it derives, and + // this is what says the two would differ if it were bound for only one of them. + test("changes when the build mode is not the one the document declares", () => { + expect(hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES, true))).not.toBe( + hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES, false)), + ); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/deployedCovenant.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/deployedCovenant.test.ts new file mode 100644 index 0000000..db3d547 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/deployedCovenant.test.ts @@ -0,0 +1,101 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * The other end of the covenant-parameter chain: what the real compiler makes of the arguments + * this wallet emits. + * + * `tx-manifest` proves that reading a live protocol's published document produces exactly the + * argument string below — it holds no compiler, by design, because a wallet supplies one. This + * proves that string is the one that reproduces the covenant the protocol is actually deployed + * at. The string is the join, written out character for character at both ends, so an encoding + * that changed at either would break one of these two files. + * + * The script is not this module's own output pinned against itself. `lending_v3.manifest.json` + * records it as the deployed factory's fixed scriptPubKey — its address depends only on the two + * integers below, not on the asset it holds, which is why one published constant covers every + * deployment of it. + */ + +/** What `tx-manifest` asks for, from the published document. Kept identical to its own copy. */ +const ARGUMENTS = + '{"ISSUING_UTXOS_COUNT":{"type":"u8","value":"2"},"REISSUANCE_FLAGS":{"type":"u64","value":"0"}}'; + +/** What the published document says the deployed factory is locked by. */ +const DEPLOYED_SCRIPT_PUB_KEY = + "5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b"; + +/** The document says its contracts were built with debug symbols, and that changes the address. */ +const DEBUG_SYMBOLS = true; + +let source = ""; + +beforeAll(async () => { + source = await contractSource("issuance_factory.simf"); +}); + +function scriptPubKeyFor(argumentsJson: string, includeDebugSymbols = DEBUG_SYMBOLS): string { + const contract = new smplx.Contract(source, argumentsJson, "[]", includeDebugSymbols); + + try { + return contract.scriptPubKeyHex("liquid"); + } finally { + contract.free(); + } +} + +describe("a deployed covenant's parameters", () => { + test("reproduce the script the protocol's own document says its factory is locked by", () => { + expect(scriptPubKeyFor(ARGUMENTS)).toBe(DEPLOYED_SCRIPT_PUB_KEY); + }); + + test("and the address that script is written as", () => { + const contract = new smplx.Contract(source, ARGUMENTS, "[]", DEBUG_SYMBOLS); + + expect(contract.contractAddress("liquid")).toBe( + "ex1pg45gz7zucl2krj42qk0q9udzsgcxd0vxqs3ej6l286fvvgdmqe9s5w0cfg", + ); + + contract.free(); + }); + + // Without the mode the document declares, the same two integers build a different covenant. + // The check is here rather than in a comment because the mode is read from a field that was + // renamed once already. + test("only in the mode the document declares them built in", () => { + expect(scriptPubKeyFor(ARGUMENTS, false)).not.toBe(DEPLOYED_SCRIPT_PUB_KEY); + }); +}); + +/** + * Why the integers are written as decimal, demonstrated rather than asserted in prose. + * + * The compiler reads `0x…` as a hexadecimal literal of exactly the type's width. A count of 2 + * hex-prefixed is `0x2`, which is one digit and no whole number of bytes, so it fails loudly. + * That is the harmless half. The dangerous half is a value whose decimal spelling happens to + * be a legal width: it compiles, it derives an address, and it is a different number. + */ +describe("the encoding that would have been wrong", () => { + test("a hex-prefixed count of the wrong width is refused by the compiler", () => { + const wrong = ARGUMENTS.replace('"value":"2"', '"value":"0x2"'); + + expect(() => scriptPubKeyFor(wrong)).toThrow(); + }); + + test("but a hex-prefixed value of the right width is a different number, silently", () => { + const asDecimal = ARGUMENTS.replace( + '"REISSUANCE_FLAGS":{"type":"u64","value":"0"}', + '"REISSUANCE_FLAGS":{"type":"u64","value":"1000000000000000"}', + ); + const asHex = ARGUMENTS.replace( + '"REISSUANCE_FLAGS":{"type":"u64","value":"0"}', + '"REISSUANCE_FLAGS":{"type":"u64","value":"0x1000000000000000"}', + ); + + // Both compile. Both derive a valid address. Neither reports anything. + expect(scriptPubKeyFor(asDecimal)).toMatch(/^5120[0-9a-f]{64}$/); + expect(scriptPubKeyFor(asHex)).toMatch(/^5120[0-9a-f]{64}$/); + expect(scriptPubKeyFor(asDecimal)).not.toBe(scriptPubKeyFor(asHex)); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index f714c01..413afc7 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -1,27 +1,25 @@ // oxlint-disable consistent-function-scoping -- each helper builds the case it sits in, and reading it beside the assertion is the point -// oxlint-disable no-underscore-dangle -- these are wasm-bindgen's own exported names; renaming them would stop the module loading -import { beforeAll, describe, expect, test } from "bun:test"; -import { readFile } from "node:fs/promises"; -import { createRequire } from "node:module"; +import { describe, expect, test } from "bun:test"; import { estimateFeeSats } from "@humid/tx-manifest"; import { guardSpentInputs } from "@humid/tx-manifest"; import { spentInputs } from "@humid/tx-manifest"; -import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js"; + +import { smplx as bindings, type SmplxBindings } from "./smplxWasmForTests"; // Exercises the exact bindings `loadSmplxWasm` consumes. The only difference is where -// the module bytes come from: the extension fetches them through a Vite asset URL, this -// reads them off disk. Everything after instantiation — the `__wbg_set_wasm` handshake, -// the start call, and every exported binding — is the same code path. +// the module bytes come from: the extension fetches them through a Vite asset URL, the +// shared fixture reads them off disk. Everything after instantiation — the +// `__wbg_set_wasm` handshake, the start call, and every exported binding — is the same +// code path. // // `loadSmplxWasm` itself cannot be imported here: it uses Vite's `?url` import, which // only resolves under Vite. - -type SmplxBindings = typeof import("smplx-wasm") & { - __wbg_set_wasm: (exports: WebAssembly.Exports) => void; -}; - -const bindings = smplxWasmBindings as unknown as SmplxBindings; +// +// The instantiation moved to `smplxWasmForTests` when a second file needed the module. +// It has to happen once per process rather than once per file: the glue is a module and +// therefore a singleton, so a second instantiation repoints it at a different memory +// while the first one's objects are still reading the old one. // The reference value: this source compiled natively against simplicityhl 0.6.0 with // debug symbols off. Asserting the wasm build reproduces it is what makes recomputing a @@ -30,24 +28,6 @@ const bindings = smplxWasmBindings as unknown as SmplxBindings; const PROBE_SOURCE = "fn main() { assert!(jet::eq_32(witness::A, witness::B)); }"; const PROBE_CMR = "43041b02608dc3ba245a2e3dc7aa5bc991fcf6c097c6a165a18e97a486461729"; -beforeAll(async () => { - const require = createRequire(import.meta.url); - const wasmPath = require.resolve("smplx-wasm/smplx_wasm_bg.wasm"); - const bytes = await readFile(wasmPath); - - const { instance } = await WebAssembly.instantiate(bytes, { - "./smplx_wasm_bg.js": bindings as unknown as WebAssembly.ModuleImports, - }); - - bindings.__wbg_set_wasm(instance.exports); - - const start = instance.exports.__wbindgen_start; - - if (typeof start === "function") { - start(); - } -}); - describe("smplx wasm module", () => { test("reports the SDK version compiled into it", () => { expect(bindings.sdkVersion()).toBe("0.0.9"); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts new file mode 100644 index 0000000..c8c782b --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts @@ -0,0 +1,54 @@ +// oxlint-disable no-underscore-dangle -- these are wasm-bindgen's own exported names; renaming them would stop the module loading +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; + +import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js"; + +/** + * The real smplx module, instantiated once for every test that needs it. + * + * **Once is not an optimisation.** The generated glue is a module, and a module is a singleton: + * `__wbg_set_wasm` points it at one instance's exports, and every handle it hands out reads + * that instance's memory. A second instantiation in the same process repoints the glue while + * the first instance's objects are still alive, so they start reading a different memory — + * which is not an error anywhere, just wrong values and torn objects. Two test files each + * bootstrapping their own module turned sixty passing assertions into failures inside the + * bindings, in a suite where neither file was doing anything wrong on its own. + * + * So the bootstrap lives here and the test files import it. Top-level await plus the module + * cache is what makes that exactly-once: whichever test file is loaded first pays for it, and + * the rest get the same instance. + * + * This is a test fixture rather than production loading. The extension fetches the module bytes + * through a Vite asset URL, which only resolves under Vite; everything after instantiation — + * the handshake, the start call, and every exported binding — is the same code path. + */ + +type SmplxBindings = typeof import("smplx-wasm") & { + __wbg_set_wasm: (exports: WebAssembly.Exports) => void; +}; + +const bindings = smplxWasmBindings as unknown as SmplxBindings; + +const require = createRequire(import.meta.url); +const bytes = await readFile(require.resolve("smplx-wasm/smplx_wasm_bg.wasm")); + +const { instance } = await WebAssembly.instantiate(bytes, { + "./smplx_wasm_bg.js": bindings as unknown as WebAssembly.ModuleImports, +}); + +bindings.__wbg_set_wasm(instance.exports); + +const start = instance.exports.__wbindgen_start; + +if (typeof start === "function") { + start(); +} + +/** Reads one of the vendored contract sources the published manifests reference. */ +export async function contractSource(name: string): Promise<string> { + return readFile(require.resolve(`@humid/tx-manifest/fixtures/contracts/${name}`), "utf8"); +} + +export { bindings as smplx }; +export type { SmplxBindings }; diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/valueWiredCovenant.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/valueWiredCovenant.test.ts new file mode 100644 index 0000000..b732058 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/valueWiredCovenant.test.ts @@ -0,0 +1,183 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * The compiler end of the parameters a deployment writes as bare values. + * + * `tx-manifest` proves that reading a live protocol's published document produces exactly the + * argument strings below, and that every value in them was typed by a declaration rather than by + * its appearance. It holds no compiler, by design. This proves the declarations it was typed + * against are the compiler's own, and that the strings build a covenant. + * + * **Where the type comes from, and why it has to be asked for.** `SimplicityHL` has no syntax + * that declares a compile parameter's type. `param::NAME` is written where a value is wanted and + * the type checker gives it the type that position demands — `simplicityhl` 0.6.0 inserts the + * parameter into the program's global map under the expected type of the expression it stands in + * for (`src/ast.rs` L1346-1350). So there is no declaration in the source to read: the type is a + * result of analysing the source, and the compiler is the only thing that can state it. + * + * **Nothing here was compared against a chain.** The published document records a deployed + * scriptPubKey for its factory and for none of these, and the asset ids are invented, so these + * addresses are reproducible rather than confirmed. Confirming one needs a deployed instance of + * these covenants whose script is either published or readable from Liquid. + */ + +const MIDDLE = "00".repeat(30); +const ZERO_HASH = "00".repeat(32); + +/** Kept character for character identical to `tx-manifest`'s own copy. */ +const PRINCIPAL_ASSET_AUTH = + `{"ASSET_ID":{"type":"u256","value":"0x0b${MIDDLE}b0"},` + + '"ASSET_AMOUNT":{"type":"u64","value":"1"},' + + '"WITH_ASSET_BURN":{"type":"bool","value":"false"}}'; + +const LENDER_VAULT_FINALIZED = + `{"VAULT_ASSET_ID":{"type":"u256","value":"0x0a${MIDDLE}a0"},` + + `"KEEPER_AUTH_ASSET_ID":{"type":"u256","value":"0x0c${MIDDLE}c0"},` + + `"SUPPLIER_AUTH_ASSET_ID":{"type":"u256","value":"0x0b${MIDDLE}b0"},` + + '"KEEPER_AUTH_ASSET_AMOUNT":{"type":"u64","value":"1"},' + + `"FINALIZED_VAULT_COV_HASH":{"type":"u256","value":"0x${ZERO_HASH}"},` + + '"IS_ACTIVE":{"type":"bool","value":"false"},' + + '"WITH_KEEPER_ASSET_BURN":{"type":"bool","value":"true"},' + + '"WITH_SUPPLIER_ASSET_BURN":{"type":"bool","value":"true"}}'; + +const PROTOCOL_FEE_VAULT_FINALIZED = LENDER_VAULT_FINALIZED.replace( + '"WITH_KEEPER_ASSET_BURN":{"type":"bool","value":"true"}', + '"WITH_KEEPER_ASSET_BURN":{"type":"bool","value":"false"}', +).replace( + `"KEEPER_AUTH_ASSET_ID":{"type":"u256","value":"0x0c${MIDDLE}c0"}`, + `"KEEPER_AUTH_ASSET_ID":{"type":"u256","value":"0x0d${MIDDLE}d0"}`, +); + +/** The document says its contracts were built with debug symbols, and that changes the address. */ +const DEBUG_SYMBOLS = true; + +let assetAuth = ""; +let assetAuthVault = ""; + +beforeAll(async () => { + assetAuth = await contractSource("asset_auth.simf"); + assetAuthVault = await contractSource("asset_auth_vault.simf"); +}); + +function scriptPubKeyFor(source: string, argumentsJson: string): string { + const contract = new smplx.Contract(source, argumentsJson, "[]", DEBUG_SYMBOLS); + const script = contract.scriptPubKeyHex("liquid"); + + contract.free(); + + return script; +} + +describe("what the contracts declare their parameters to be", () => { + test("asset_auth declares a count, an id and a flag", () => { + expect(JSON.parse(smplx.contractParameterTypes(assetAuth))).toEqual({ + ASSET_AMOUNT: "u64", + ASSET_ID: "u256", + WITH_ASSET_BURN: "bool", + }); + }); + + test("asset_auth_vault declares three flags among its eight", () => { + expect(JSON.parse(smplx.contractParameterTypes(assetAuthVault))).toEqual({ + FINALIZED_VAULT_COV_HASH: "u256", + IS_ACTIVE: "bool", + KEEPER_AUTH_ASSET_AMOUNT: "u64", + KEEPER_AUTH_ASSET_ID: "u256", + SUPPLIER_AUTH_ASSET_ID: "u256", + VAULT_ASSET_ID: "u256", + WITH_KEEPER_ASSET_BURN: "bool", + WITH_SUPPLIER_ASSET_BURN: "bool", + }); + }); + + /** + * The reading needs no arguments, which is the whole point of it: the arguments cannot be + * built until the types are known, so anything that had to be given them first would be + * circular. Asserted against a contract whose parameters nothing here supplies. + */ + test("and are readable from the source alone, with no arguments supplied", async () => { + expect( + JSON.parse(smplx.contractParameterTypes(await contractSource("lending.simf"))), + ).toMatchObject({ LOAN_EXPIRATION_TIME: "u32", PRINCIPAL_AMOUNT: "u64" }); + }); + + test("a source that is not a program is refused rather than answered", () => { + expect(() => smplx.contractParameterTypes("fn main() { this is not simplicity }")).toThrow(); + }); +}); + +describe("the argument strings tx-manifest builds from those declarations", () => { + test("build the covenant behind claiming the principal", () => { + expect(scriptPubKeyFor(assetAuth, PRINCIPAL_ASSET_AUTH)).toMatch(/^5120[0-9a-f]{64}$/); + }); + + test("build the lender's finalised vault", () => { + expect(scriptPubKeyFor(assetAuthVault, LENDER_VAULT_FINALIZED)).toMatch(/^5120[0-9a-f]{64}$/); + }); + + test("build the protocol fee's finalised vault", () => { + expect(scriptPubKeyFor(assetAuthVault, PROTOCOL_FEE_VAULT_FINALIZED)).toMatch( + /^5120[0-9a-f]{64}$/, + ); + }); + + /** + * The two vaults differ by one word in the document. If a flag were read as a flag rather + * than as the type its contract declares, both would still compile — to the same address for + * one of them and the wrong address for the other. + */ + test("and the two vaults are different covenants, because one flag differs", () => { + expect(scriptPubKeyFor(assetAuthVault, LENDER_VAULT_FINALIZED)).not.toBe( + scriptPubKeyFor(assetAuthVault, PROTOCOL_FEE_VAULT_FINALIZED), + ); + }); +}); + +/** + * Why the width had to be asked for rather than picked. + * + * A count of one fits every integer type there is, so nothing about the value narrows it. What + * saves a wrong pick from being silent is that the compiler requires an argument's type to equal + * its parameter's exactly — but "saved by a refusal deep in the compiler" is not the same as + * knowing, and the refusal names neither the document nor the parameter. + */ +describe("the widths that would have been wrong", () => { + for (const wrong of ["u8", "u16", "u32", "u128", "u256"]) { + test(`the same count declared ${wrong} does not build a covenant at all`, () => { + const mutated = PRINCIPAL_ASSET_AUTH.replace( + '"ASSET_AMOUNT":{"type":"u64","value":"1"}', + `"ASSET_AMOUNT":{"type":"${wrong}","value":"1"}`, + ); + + expect(() => scriptPubKeyFor(assetAuth, mutated)).toThrow(); + }); + } + + test("and a flag given an integer type instead of its own is refused too", () => { + const mutated = PRINCIPAL_ASSET_AUTH.replace( + '"WITH_ASSET_BURN":{"type":"bool","value":"false"}', + '"WITH_ASSET_BURN":{"type":"u8","value":"0"}', + ); + + expect(() => scriptPubKeyFor(assetAuth, mutated)).toThrow(); + }); + + /** + * The half that is not saved by a refusal. Both words are legal `bool`, so setting the wrong + * one compiles, derives an address, and reports nothing — which is why the word is read from + * the document and never defaulted. + */ + test("but the wrong word for a flag is silent, and a different covenant", () => { + const flipped = PRINCIPAL_ASSET_AUTH.replace( + '"WITH_ASSET_BURN":{"type":"bool","value":"false"}', + '"WITH_ASSET_BURN":{"type":"bool","value":"true"}', + ); + + expect(scriptPubKeyFor(assetAuth, flipped)).toMatch(/^5120[0-9a-f]{64}$/); + expect(scriptPubKeyFor(assetAuth, flipped)).not.toBe( + scriptPubKeyFor(assetAuth, PRINCIPAL_ASSET_AUTH), + ); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts index 11f1bd9..bbd0d46 100644 --- a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts +++ b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts @@ -130,6 +130,15 @@ export type LiquidWalletBackend = { getActivity: (account: LiquidWalletAccount, rawAssetId: string) => LiquidActivityEntry[]; getBalance: (account: LiquidWalletAccount, rawAssetId: string) => string; getReceiveAddress: (account: LiquidWalletAccount) => { address: string; index: number }; + /** + * The address a contract action can spend from, which is not the one shown for receiving. + * + * The signing module derives a single key at the account's first external address and signs + * every wallet input with it, so that address is the whole of what a contract action can be + * funded from. An output paid back to this wallet anywhere else is money this path cannot + * spend again — and every protocol that hands a token back expects to spend it next. + */ + getSigningAddress: (account: LiquidWalletAccount) => { address: string; index: number }; getDescriptorEntries: ( account: LiquidWalletAccount, params: LiquidGetWalletDescriptorParams, @@ -143,6 +152,14 @@ export type LiquidWalletBackend = { * spend an output whose amount is hidden. */ getExplicitUtxos: (account: LiquidWalletAccount, rawAssetId: string) => LiquidUTXO[]; + /** + * How high the chain is, as the wallet's own scan reached it. + * + * A covenant branch guarded by a lock height reads the transaction's locktime, and the + * wallet has to declare one. Answered from the scan rather than from an endpoint, because + * a plain chain-tip route is not universal across the backends this wallet supports. + */ + getTipHeight: (account: LiquidWalletAccount) => number; inspectTransfer: ( account: LiquidWalletAccount, params: LiquidSendTransferParams, diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx index f413b61..2a39d1d 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx @@ -3,7 +3,9 @@ import { describe, expect, test } from "bun:test"; import { computed, fromSite, verified } from "@humid/tx-manifest"; import { + feeLine, isProcessCtConfirmationData, + netEffectLine, PROCESS_CT_CONFIRMATION_KIND, processCtConfirmationRenderer, } from "./ProcessCtConfirmation"; @@ -23,12 +25,26 @@ const MODEL = { verified: computed(true), }, ], + feeAsset: computed("lbtc"), feeSats: computed(500n), netEffect: [{ asset: computed("lbtc"), sats: computed(-50_500n) }], protocol: fromSite("p2pk-simplicity"), summary: fromSite("Spend a p2pk output back into your wallet."), }; +describe("the fee, which is a price rather than a balance change", () => { + // The balance lines carry a sign because they say which way money moved. The fee is what + // this transaction costs, and it was rendered by the same function — so a wallet paying a + // fee printed "+0.00000108 L-BTC" one line under "−0.00000108 L-BTC" for the same amount. + test("is written without a sign", () => { + expect(feeLine("108")).toBe("0.00000108 L-BTC"); + }); + + test("still names the asset the network charges in", () => { + expect(feeLine("0")).toBe("0 L-BTC"); + }); +}); + describe("the contract-action confirmation", () => { test("recognises the payload the method builds", () => { expect( @@ -60,3 +76,30 @@ describe("the contract-action confirmation", () => { ).toBeNull(); }); }); + +// One balance change per asset reaches this surface now, and only one of them is in an asset +// this wallet knows how to name and how to divide. +describe("a balance change in each asset the action moves", () => { + const FEE_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + const TOKEN = "aa".repeat(32); + + test("the network's own asset is shown by name, divided the way it divides", () => { + expect(netEffectLine({ asset: FEE_ASSET, sats: "-50500" }, FEE_ASSET)).toEqual({ + shown: "−0.000505 L-BTC", + }); + }); + + // A protocol's own token divides however that protocol says, which this wallet was never + // told. Base units and the id are what it can stand behind; "0.00000001 L-BTC" beside a + // one-of-a-kind token would be two lies in five characters. + test("and any other asset is shown in base units, beside the id it is", () => { + expect(netEffectLine({ asset: TOKEN, sats: "-1" }, FEE_ASSET)).toEqual({ + asset: TOKEN, + shown: "−1", + }); + }); + + test("with the sign kept, because which way it goes is the whole point", () => { + expect(netEffectLine({ asset: TOKEN, sats: "250000" }, FEE_ASSET).shown).toBe("+250000"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx index a47cb62..2038eb8 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx @@ -51,11 +51,57 @@ function Shown({ label, value }: { label: string; value: Provenanced<string> }) */ function amount(value: string): string { const sats = BigInt(value); - const negative = sats < 0n; - const whole = (negative ? -sats : sats).toString().padStart(9, "0"); - const point = `${whole.slice(0, -8)}.${whole.slice(-8)}`.replace(/\.?0+$/, ""); - return `${negative ? "−" : "+"}${point || "0"} L-BTC`; + return `${sats < 0n ? "−" : "+"}${decimal(sats)} L-BTC`; +} + +/** + * The fee, written without a sign. + * + * The balance lines carry one because they say which way money moved; the fee is what this + * transaction costs. Sharing the balance formatter printed the cost as a gain — "+0.00000108 + * L-BTC" directly under "−0.00000108 L-BTC", the same figure twice with opposite signs. + */ +export function feeLine(value: string): string { + return `${decimal(BigInt(value))} L-BTC`; +} + +/** One L-BTC figure, unsigned, with trailing zeros trimmed. */ +function decimal(sats: bigint): string { + const whole = (sats < 0n ? -sats : sats).toString().padStart(9, "0"); + + return `${whole.slice(0, -8)}.${whole.slice(-8)}`.replace(/\.?0+$/, "") || "0"; +} + +/** + * One line of the balance change, in whichever terms this wallet can honestly write it. + * + * A pure function rather than a branch inside the markup, because this is the one decision on + * this surface that can be got wrong quietly: printing a token's units under the network + * asset's name reads as money and is not, and there is no rendering test in this project that + * would catch it. + */ +export function netEffectLine( + effect: { asset: string; sats: string }, + feeAsset: string, +): { asset?: string; shown: string } { + return effect.asset === feeAsset + ? { shown: amount(effect.sats) } + : { asset: effect.asset, shown: units(effect.sats) }; +} + +/** + * The same figure in an asset this wallet knows nothing else about. + * + * Base units and a sign, and no name and no decimal point: how many places a protocol's own + * token divides into is the protocol's business, and a wallet guessing eight of them would + * print a hundredth of a token as a whole one. The id sits beside it, which is the only thing + * about that asset this wallet actually established. + */ +function units(value: string): string { + const sats = BigInt(value); + + return `${sats < 0n ? "−" : "+"}${(sats < 0n ? -sats : sats).toString()}`; } /** @@ -87,23 +133,68 @@ export function ProcessCtConfirmation({ </header> <div className="flex-1 space-y-5 overflow-y-auto px-4"> - {shown.netEffect.map((effect) => ( - <div className="flex flex-col gap-1" key={effect.asset.value}> + {shown.netEffect.map((effect) => { + const line = netEffectLine( + { asset: effect.asset.value, sats: effect.sats.value }, + shown.feeAsset.value, + ); + + return ( + <div className="flex flex-col gap-1" key={effect.asset.value}> + <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> + This wallet + </span> + <span className="text-lg font-semibold">{line.shown}</span> + {line.asset === undefined ? null : ( + <span className="text-muted-foreground text-xs break-all">{line.asset}</span> + )} + <span className="text-muted-foreground text-xs"> + {describeOrigin(effect.sats.origin)} + </span> + </div> + ); + })} + + <Shown + label="Network fee" + value={{ ...shown.feeSats, value: feeLine(shown.feeSats.value) } as Provenanced<string>} + /> + <Shown label="Acting account" value={shown.account} /> + + {/* What this transaction keeps off the chain, one line each, with whose word + decided it. The wallet hid these on someone's behalf, so it says so — and says + which of them the protocol asked for and which it simply never mentioned. */} + {shown.hiddenAmounts.map((hidden) => ( + <div className="flex flex-col gap-1" key={hidden.id.value}> <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> - This wallet + Amount hidden on chain </span> - <span className="text-lg font-semibold">{amount(effect.sats.value)}</span> + <span className="text-sm font-medium break-all">{hidden.id.value}</span> + <span className="text-sm">{hidden.decidedBy.value}</span> <span className="text-muted-foreground text-xs"> - {describeOrigin(effect.sats.origin)} + {describeOrigin(hidden.decidedBy.origin)} </span> </div> ))} - <Shown - label="Network fee" - value={{ ...shown.feeSats, value: amount(shown.feeSats.value) } as Provenanced<string>} - /> - <Shown label="Acting account" value={shown.account} /> + {/* And what it publishes that the format would have kept off the chain: a contract + action's own change, which this wallet returns in the open so the money comes + back in a form the next action can be funded from. It says which word it set + aside to do that, because overriding a protocol quietly — here of all places, + where the person was just told to trust this wallet's reading of it — would be + worth less than not having told them anything. */} + {shown.publishedAmounts.map((published) => ( + <div className="flex flex-col gap-1" key={published.id.value}> + <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> + Amount published on chain + </span> + <span className="text-sm font-medium break-all">{published.id.value}</span> + <span className="text-sm">{published.reason.value}</span> + <span className="text-muted-foreground text-xs"> + {describeOrigin(published.reason.origin)} + </span> + </div> + ))} {shown.covenants.map((covenant) => ( <div className="flex flex-col gap-1" key={covenant.address.value}> diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 0d9e9ed..1353def 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { txOutAt } from "@humid/tx-manifest"; +import { spentInputs, txOutAt } from "@humid/tx-manifest"; import groupedManifest from "@humid/tx-manifest/fixtures/p2pk-grouped.manifest.json"; import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; @@ -49,18 +49,28 @@ function requireBlindingKey(what: string, value: string): void { } const DERIVED = "tex1p_derived"; const WALLET_ADDRESS = "tex1q_wallet"; +const ROTATING_ADDRESS = "tex1q_rotating"; const WALLET_SCRIPT = "0014" + "11".repeat(20); +const ROTATING_SCRIPT = "0014" + "99".repeat(20); const BLINDING_KEY = `02${PUBKEY}`; const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; const FUNDING_TXID = "d".repeat(64); /** - * An Elements transaction serialised as far as its inputs, which is what the input guard - * reads. The substituted module builds one from what it was actually told to spend, so the - * guard is exercised against the shape of the request rather than against a constant that - * would agree with it whatever happened. + * An Elements transaction serialised from what the substituted module was actually told, + * inputs and outputs both, so each guard is exercised against the shape of the request rather + * than against a constant that would agree with it whatever happened. + * + * The output half arrived with the blinding guard and is the reason it can run at all: a + * substitute that stopped after the inputs returned bytes with no outputs in them, which reads + * as a transaction that builds nothing and could never disagree with the wallet about what it + * hid. The module's own order is reproduced — the action's outputs where the wallet put them, + * then the change it appends, then the fee — because the guard finds the change by position. */ -function serialise(spends: { txid: string; vout: number }[]): string { +function serialise( + spends: { txid: string; vout: number }[], + built: Built = { changeBlinded: false, outputs: [] }, +): string { const inputs = spends .map(({ txid, vout }) => { const reversed = (txid.match(/../g) ?? []).toReversed().join(""); @@ -70,7 +80,43 @@ function serialise(spends: { txid: string; vout: number }[]): string { }) .join(""); - return `0200000001${spends.length.toString(16).padStart(2, "0")}${inputs}`; + const outputs = [ + ...built.outputs.map((output) => txOutOf(output.blinded, output.script)), + txOutOf(built.changeBlinded, WALLET_SCRIPT), + // The fee, which carries no script at all: the network reads the amount it charges. + txOutOf(false, ""), + ]; + + return ( + `0200000001${count(spends.length)}${inputs}` + + `${count(outputs.length)}${outputs.join("")}00000000` + ); +} + +/** What the substituted builder was told to build, in the order it was told. */ +type Built = { changeBlinded: boolean; outputs: { blinded: boolean; script: string }[] }; + +function count(value: number): string { + return value.toString(16).padStart(2, "0"); +} + +/** + * One output the way the chain writes one. + * + * An explicit amount is a `01` prefix and eight bytes; a hidden one is a commitment prefix and + * thirty-two, with a nonce beside it. Written as bytes rather than as a flag, because the only + * thing that can establish what a transaction hides is what it is made of. + */ +function txOutOf(blinded: boolean, scriptHex: string): string { + const length = count(scriptHex.length / 2); + + if (blinded) { + return `0a${"33".repeat(32)}08${"44".repeat(32)}02${"55".repeat(32)}${length}${scriptHex}`; + } + + const asset = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + + return `01${asset}01${(1000).toString(16).padStart(16, "0")}00${length}${scriptHex}`; } function params(overrides: Record<string, unknown> = {}) { @@ -93,7 +139,12 @@ function context(): LiquidProcessCtContext { }, keyManagerState: {}, walletBackend: { - getReceiveAddress: () => ({ address: WALLET_ADDRESS, index: 0 }), + // The address a person is shown to receive at moves as addresses are used. The one a + // contract action can spend from does not: the signing module derives a single key at + // the account's first external address. The two differ here so a path taking the wrong + // one is visible. + getReceiveAddress: () => ({ address: ROTATING_ADDRESS, index: 7 }), + getSigningAddress: () => ({ address: WALLET_ADDRESS, index: 0 }), // The two lists the method reads, kept honest about which is which: a contract action // can only spend an explicit output, so the funding one lives in the explicit list and // the confidential one is there to be held back. A method that stopped asking for the @@ -118,6 +169,9 @@ function context(): LiquidProcessCtContext { vout: 0, }, ], + // The height the wallet's own scan reached, which is what a covenant branch guarded by + // a lock height reads out of the transaction it judges. + getTipHeight: () => 2_580_990, syncAccount: async () => undefined, }, } as unknown as LiquidProcessCtContext; @@ -160,14 +214,49 @@ type IssuedInput = { type Recorded = { broadcasts: { txHex: string }[]; + /** + * The height the builder was told the transaction may not be mined before. + * + * Read from the builder rather than from the review, so a method that stopped passing it + * through fails here rather than agreeing with itself. + */ + locktimeHeight?: number; + /** + * Whether the builder was told to hide the change it returns. + * + * Read from the builder rather than from the review, so a method that stopped passing the + * decision through fails here instead of agreeing with itself. + */ + changeBlinded: boolean; + /** Every contract source the review asked for the declarations of. */ + declared: string[]; issued: IssuedInput[]; mnemonicCalls: number; + /** Every output as the builder was told it: the asset and whether it hides what it carries. */ + outputs: { asset: string; blinded: boolean; script: string }[]; paid: string[]; + /** Every address the method asked for the script of, in order. */ + scriptAsks: string[]; + /** How each covenant input was described to the builder, beyond its source. */ + covenantBuilds: { includeDebugSymbols?: boolean; leaves?: string }[]; + /** The transaction the substituted module handed back, as it handed it back. */ + signed: string; }; +/** + * What a module does to the transaction between being told and handing it back. + * + * The identity by default, because a module that does what it is told is the case worth + * running everything else against. A test supplies one when it needs the other case — the + * module ignoring what it was told, which is precisely what the guards exist to catch and is + * unreachable from a substitute that can only be obedient. + */ +type ModuleBehaviour = (built: Built) => Built; + function dependencies( recorded: Recorded, issued: IssuanceAccount = ISSUED, + behaviour: ModuleBehaviour = (built) => built, ): LiquidProcessCtDependencies { return { broadcastTransaction: async ({ txHex }) => { @@ -178,6 +267,20 @@ function dependencies( loadSmplx: async () => ({ compilerVersion: () => "0.6.0", + // What a contract declares its compile parameters to be, which the real module + // answers by type-checking the source. A substitute cannot type a parameter — + // needing the compiler for exactly that is why this seam exists — so it answers + // only for a source that declares none, and refuses the rest rather than + // inventing a width that would silently be part of an address. + contractParameterTypes: (source: string) => { + recorded.declared.push(source); + + if (/\bparam::/.test(source)) { + throw new Error("This substitute cannot say what a contract declares."); + } + + return "{}"; + }, Contract: class { contractAddress() { return DERIVED; @@ -195,11 +298,20 @@ function dependencies( // it decodes, this decodes. TransactionBuilder: class { change: string | undefined; + changeBlinded = false; + /** The height the method declared, so a transaction that stops declaring one shows here. */ + locktimeHeight: number | undefined; + /** Each output as it was told, so the transaction it returns carries them. */ + outputs: { blinded: boolean; script: string }[] = []; spends: { txid: string; vout: number }[] = []; // The change target moved onto the builder, and so did the parse that rejects // one it cannot read. Recorded rather than swallowed, so a method that stopped // stating where change goes fails here instead of sending it to the module's // own default in silence. + setLocktimeHeight(height: number) { + this.locktimeHeight = height; + recorded.locktimeHeight = height; + } addChange(scriptPubKeyHex: string, blindingKeyHex?: string) { requireHex("change script", scriptPubKeyHex); @@ -208,11 +320,24 @@ function dependencies( } this.change = scriptPubKeyHex; + this.changeBlinded = blindingKeyHex !== undefined; } - addContractInput(txid: string, vout: number, txOutHex: string) { + addContractInput( + txid: string, + vout: number, + txOutHex: string, + _source: string, + _argumentsJson: string | undefined, + _witnessJson: string | undefined, + _signatureWitness: string | undefined, + _sequence: number | undefined, + extraLeavesJson: string | undefined, + includeDebugSymbols: boolean | undefined, + ) { requireHex("covenant input's previous output", txOutHex); requireTxid(txid); this.spends.push({ txid, vout }); + recorded.covenantBuilds.push({ includeDebugSymbols, leaves: extraLeavesJson }); } addContractIssuanceInput( txid: string, @@ -253,6 +378,15 @@ function dependencies( requireBlindingKey("output blinding key", blindingKeyHex); } + this.outputs.push({ + blinded: blindingKeyHex !== undefined, + script: scriptPubKeyHex, + }); + recorded.outputs.push({ + asset: assetHex, + blinded: blindingKeyHex !== undefined, + script: scriptPubKeyHex, + }); recorded.paid.push(scriptPubKeyHex); } addWalletInput(txid: string, vout: number, txOut: string) { @@ -292,17 +426,23 @@ function dependencies( return BLINDING_KEY; } finalizeTransaction( - builder: { change?: string; spends: { txid: string; vout: number }[] }, + builder: Built & { change?: string; spends: { txid: string; vout: number }[] }, _feeRateSatsPerKvb: number, ) { if (builder.change === undefined) { throw new Error("The transaction was finalised without a change target."); } + recorded.changeBlinded = builder.changeBlinded; + // Kept, so a test asserting the method returns what the module built can say + // exactly that rather than assemble the same bytes a second time and compare + // two derivations of one thing. + recorded.signed = serialise(builder.spends, behaviour(builder)); + return { feeSats: 500n, free: () => undefined, - hex: serialise(builder.spends), + hex: recorded.signed, txid: "e".repeat(64), }; } @@ -329,7 +469,11 @@ function dependencies( }, resolveAccount: async () => ({ accountGroupIndex: 0, chain: {}, rawPolicyAssetId: POLICY_ASSET }) as never, - scriptPubKeyHexOf: async () => WALLET_SCRIPT, + scriptPubKeyHexOf: async (address: string) => { + recorded.scriptAsks.push(address); + + return address === WALLET_ADDRESS ? WALLET_SCRIPT : ROTATING_SCRIPT; + }, withMnemonic: async (_request, use) => { recorded.mnemonicCalls += 1; @@ -338,11 +482,22 @@ function dependencies( }; } -function subject(issued: IssuanceAccount = ISSUED) { - const recorded: Recorded = { broadcasts: [], issued: [], mnemonicCalls: 0, paid: [] }; +function subject(issued: IssuanceAccount = ISSUED, behaviour?: ModuleBehaviour) { + const recorded: Recorded = { + broadcasts: [], + changeBlinded: false, + declared: [], + issued: [], + covenantBuilds: [], + mnemonicCalls: 0, + scriptAsks: [], + outputs: [], + paid: [], + signed: "", + }; return { - method: createProcessLiquidConfidentialTransaction(dependencies(recorded, issued)), + method: createProcessLiquidConfidentialTransaction(dependencies(recorded, issued, behaviour)), recorded, }; } @@ -354,7 +509,7 @@ describe("processLiquidConfidentialTransaction", () => { const result = await method(params(), context()); expect(result).toMatchObject({ broadcast: false, feeSats: "500" }); - expect(result.transactionHex).toBe(serialise([{ txid: FUNDING_TXID, vout: 0 }])); + expect(result.transactionHex).toBe(recorded.signed); expect(recorded.broadcasts).toHaveLength(0); }); @@ -363,7 +518,7 @@ describe("processLiquidConfidentialTransaction", () => { const result = await method(params({ broadcast: true }), context()); - expect(recorded.broadcasts).toEqual([{ txHex: serialise([{ txid: FUNDING_TXID, vout: 0 }]) }]); + expect(recorded.broadcasts).toEqual([{ txHex: recorded.signed }]); expect(result).toMatchObject({ broadcast: true, txid: "f".repeat(64) }); }); @@ -376,6 +531,23 @@ describe("processLiquidConfidentialTransaction", () => { expect(recorded.mnemonicCalls).toBe(1); }); + /** + * The wallet supplies the compiler, and this is the second thing the review asks it for: what + * a contract declares its compile parameters to be. It is asked before the contract is built, + * because a parameter a deployment writes as a bare value has no type until the contract + * states one, and the arguments cannot be assembled without it. + * + * Asserted here rather than only at the seam because a seam nothing fills is not delivered. + * Every covenant this wallet reviews goes through the same call. + */ + test("asks the compiler what each contract declares, passing the source it was given", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.declared).toContain(SOURCE); + }); + test("refuses a request missing the contract source, naming it", async () => { const { method, recorded } = subject(); @@ -444,12 +616,120 @@ describe("processLiquidConfidentialTransaction across declaration shapes", () => }); }); +/** + * The order the inputs are actually built in, which is the document's wherever it states one. + * + * A covenant introspects positions, so this is the last place the order can still be got wrong: + * the review works out where each input goes and the builder is what puts it there. Every + * covenant used to be added first and the wallet's own outputs after, so a document requiring + * one of the wallet's own to go first was refused rather than built — and the published + * contracts that fix an input at index zero fix one the wallet supplies. + * + * Read off the signed transaction's own bytes rather than off the review, because what a module + * was told and what it built are two different claims. + */ +const COVENANT_TXID = "a".repeat(64); +const spending = { + action: "Receive", + params: { pubkey: PUBKEY }, + state: { utxos: [{ txid: COVENANT_TXID, utxo_type: "p2pk_output", vout: 0 }] }, +}; + +/** `Receive`, with its inputs told where to go. */ +function requiring(positions: Record<string, number>) { + const document = structuredClone(p2pkManifest) as unknown as { + actions: { Receive: { inputs: Record<string, unknown>[] } }; + }; + + for (const input of document.actions.Receive.inputs) { + const at = positions[String(input.id)]; + + if (at !== undefined) { + input.required_index = at; + } + } + + return document; +} + +/** The outpoints the finished transaction spends, in the order it spends them. */ +function orderOf(transactionHex: string) { + const found = spentInputs(transactionHex); + + if (!found.ok) { + throw new Error(found.reason); + } + + return found.spent; +} + +/** + * A covenant branch guarded by a lock height reads the transaction's own locktime, and a + * transaction declaring none satisfies no such branch. No document in the corpus states one, + * because the height a spend becomes valid at is a fact about the chain — so the wallet reads + * the chain and tells the module, and this is where that stops being silent if it stops. + */ +describe("the height the transaction declares", () => { + test("is the chain's own, handed to the module that builds it", async () => { + const { method, recorded } = subject(); + + await method(params(spending), context()); + + expect(recorded.locktimeHeight).toBe(2_580_990); + }); +}); + +describe("the order the transaction's inputs are built in", () => { + test("is the wallet's own — covenant first — while the document states nothing", async () => { + const result = await subject().method(params(spending), context()); + + expect(orderOf(result.transactionHex)).toEqual([ + { txid: COVENANT_TXID, vout: 0 }, + { txid: FUNDING_TXID, vout: 0 }, + ]); + }); + + test("and puts the wallet's own input first when the document requires that", async () => { + const result = await subject().method( + params({ ...spending, manifest: requiring({ fee_input: 0, p2pk_in: 1 }) }), + context(), + ); + + expect(orderOf(result.transactionHex)).toEqual([ + { txid: FUNDING_TXID, vout: 0 }, + { txid: COVENANT_TXID, vout: 0 }, + ]); + }); + + // Being able to reorder is not a way to stop refusing: two inputs cannot both be input zero, + // and the wallet says so before anything is signed rather than after the network rejects it. + test("while a position no order could satisfy is refused, and nothing is signed", async () => { + const { method, recorded } = subject(); + + await expect( + method(params({ ...spending, manifest: requiring({ fee_input: 0, p2pk_in: 0 }) }), context()), + ).rejects.toThrow(/fee_input/); + expect(recorded.mnemonicCalls).toBe(0); + }); +}); + // AC-11 at the seam it actually protects: the guard reads the finished transaction's own // bytes, so a module that spends something nobody asked for is caught even though every // other part of the request was well formed. describe("processLiquidConfidentialTransaction guards what it signs", () => { function subjectSpending(extra: { txid: string; vout: number }) { - const recorded: Recorded = { broadcasts: [], issued: [], mnemonicCalls: 0, paid: [] }; + const recorded: Recorded = { + broadcasts: [], + changeBlinded: false, + covenantBuilds: [], + declared: [], + issued: [], + mnemonicCalls: 0, + scriptAsks: [], + outputs: [], + paid: [], + signed: "", + }; const dependency = dependencies(recorded); return { @@ -582,6 +862,47 @@ describe("what the person is actually shown", () => { expect(data.shown.netEffect[0]?.sats.origin).toBe("computed"); expect(data.shown.protocol.origin).toBe("site"); }); + + // The wallet hides amounts on someone's behalf, so it says which and on whose word. This + // action pays a covenant and returns change, and only one of those can hide anything: a + // Simplicity program reads exact amounts through jets that cannot introspect a + // commitment, so the covenant output is not on this list and cannot be. + // + // Neither is the change, and it used to be the only thing on it. The wallet publishes a + // contract action's own change now, so this action hides nothing at all. + test("and every amount it hides, with whose word decided each one", async () => { + const request = await shownRequest(); + const data = request?.data as ProcessCtConfirmationData; + + expect(data.shown.hiddenAmounts).toEqual([]); + }); + + // And the amount it publishes instead. The sentence has to lead with the word that was set + // aside — here nobody asked, and this network's own answer is to hide — before it says the + // wallet published it anyway. A wallet that overrode a protocol without saying so, in the + // one place this person was just told to trust its reading, would be worth less than one + // that had told them nothing. + test("and every amount it publishes that the format would have hidden", async () => { + const request = await shownRequest(); + const data = request?.data as ProcessCtConfirmationData; + + expect( + data.shown.publishedAmounts.map((published) => ({ + id: published.id.value, + // The reading is this wallet's, and so is the rule it applied, so it says so. + origin: published.reason.origin, + reason: published.reason.value, + })), + ).toEqual([ + { + id: "change", + origin: "computed", + reason: + "nothing says otherwise and this network hides an output by default, and this " + + "wallet publishes it anyway so your next action can spend it", + }, + ]); + }); }); // The transaction builder hex-decodes every output script it is given, so a value that is @@ -612,6 +933,51 @@ describe("what the outputs actually pay to", () => { }); }); +describe("how a covenant being spent is rebuilt", () => { + /* + * The script a covenant locks to is decided by four things: the source, the parameters, the + * extra taproot leaves and the build mode. The review compiles with all four and compares the + * result against the chain; the module that signs compiles again, and used to be told only the + * first two. A document declaring debug symbols therefore reviewed clean and failed at + * execution with a script-pubkey mismatch, after the person had approved it. + */ + test("is told the leaves and the build mode the review verified it under", async () => { + const { method, recorded } = subject(); + + // `Receive` spends the covenant rather than paying into one, which is the case where the + // module compiles a contract that already exists on chain. + await method(params(spending), context()); + + expect(recorded.covenantBuilds.length).toBeGreaterThan(0); + for (const build of recorded.covenantBuilds) { + expect(build.includeDebugSymbols).toBeBoolean(); + expect(build.leaves).toBeString(); + } + }); +}); + +describe("where an output paid to this wallet lands", () => { + /* + * A contract action can spend only what sits at the account's first external address — + * the signing module derives one key, at that index, and signs every wallet input with it. + * An output paid back to this wallet at any other address is money this path cannot spend + * again, which is what happened live: a factory's auth token landed on a rotating address + * and the next action, which has to spend it, could never find it. + */ + test("is the address this path can spend from, not the one shown for receiving", async () => { + const { method, recorded } = subject(); + + // A protocol that hands units back: the output carrying them is destined for the wallet, + // which is the case that goes wrong on a rotating address. + await method(params({ manifest: issuingManifest() }), context()); + + expect(recorded.scriptAsks).toContain(WALLET_ADDRESS); + expect(recorded.scriptAsks).not.toContain(ROTATING_ADDRESS); + expect(recorded.paid).toContain(WALLET_SCRIPT); + expect(recorded.paid).not.toContain(ROTATING_SCRIPT); + }); +}); + /** * The same protocol with its funding input creating an asset. * @@ -623,7 +989,7 @@ function issuingManifest( issuance: Record<string, unknown> = { asset_amount_sat: 1_000, kind: "new" }, ) { const manifest = structuredClone(p2pkManifest) as unknown as { - actions: { Pay: { inputs: Record<string, unknown>[] } }; + actions: { Pay: { inputs: Record<string, unknown>[]; outputs: Record<string, unknown>[] } }; }; const [funding] = manifest.actions.Pay.inputs; @@ -632,6 +998,19 @@ function issuingManifest( } funding.issuance = issuance; + // Where the created units land. An issuance mints them into the transaction, and a + // transaction holding units no output accounts for is one the network will not balance — + // so every published protocol that issues something also declares where it goes, and a + // fixture that did not was asserting against a transaction nobody could have broadcast. + funding.on_resolved = { set: { "instance.MINTED_ASSET": "asset" } }; + manifest.actions.Pay.outputs.push({ + amount_sat: issuance.asset_amount_sat, + asset: "instance.MINTED_ASSET", + confidential: false, + description: "The units this action created, returned to the wallet that made them.", + destination: "wallet", + id: "minted_out", + }); return manifest; } @@ -660,11 +1039,11 @@ describe("an input that creates an asset", () => { }); test("and the transaction it signs spends that same output", async () => { - const { method } = subject(); + const { method, recorded } = subject(); const result = await method(params({ manifest: issuingManifest() }), context()); - expect(result.transactionHex).toBe(serialise([{ txid: FUNDING_TXID, vout: 0 }])); + expect(result.transactionHex).toBe(recorded.signed); }); test("while an action that creates nothing tells the builder about no issuance", async () => { @@ -697,13 +1076,31 @@ describe("an input that creates an asset", () => { // out for themselves, from the same output. They should agree, and a silent disagreement // means one of them is creating a different asset than the other with nothing downstream able // to tell which. +// An output pays in the asset the document states for it. Every output used to be built in +// this account's policy asset, so a protocol moving its own token would have paid real money +// to a covenant expecting the token — a transaction the wallet would have signed. +describe("what asset each output is built in", () => { + test("is the one the review worked out, not this account's policy asset", async () => { + const { method, recorded } = subject(); + + await method(params({ manifest: issuingManifest() }), context()); + + const minted = recorded.outputs.filter((output) => output.asset === ISSUED.assetId); + + expect(minted.length).toBe(1); + // And the rest of them are still the network's own, so this is a distinction rather than + // a second blanket assumption. + expect(recorded.outputs.some((output) => output.asset === POLICY_ASSET)).toBe(true); + }); +}); + describe("when the module disagrees about the asset it issued", () => { test("the two derivations agreeing is what lets the transaction be signed", async () => { - const { method } = subject(); + const { method, recorded } = subject(); const result = await method(params({ manifest: issuingManifest() }), context()); - expect(result.transactionHex).toBe(serialise([{ txid: FUNDING_TXID, vout: 0 }])); + expect(result.transactionHex).toBe(recorded.signed); }); test("a different asset refuses, and says which value disagreed", async () => { @@ -749,7 +1146,7 @@ describe("when the module disagrees about the asset it issued", () => { // could pass while the values differ, so both are lowered and the same value written the // other way round is still the same value. test("the same value in another case is not a disagreement", async () => { - const { method } = subject({ + const { method, recorded } = subject({ assetId: ISSUED.assetId.toUpperCase(), entropy: ISSUED.entropy.toUpperCase(), reissuanceTokenId: ISSUED.reissuanceTokenId.toUpperCase(), @@ -757,6 +1154,106 @@ describe("when the module disagrees about the asset it issued", () => { const result = await method(params({ manifest: issuingManifest() }), context()); - expect(result.transactionHex).toBe(serialise([{ txid: FUNDING_TXID, vout: 0 }])); + expect(result.transactionHex).toBe(recorded.signed); + }); +}); + +/** + * The check that the transaction hides what the wallet decided to hide. + * + * Which outputs hide anything is settled while the document is read, and all that reaches the + * module is a blinding key or nothing. Whether it was applied is only visible in the bytes, + * and until this guard nothing looked: the method handed the module a key, took back a + * transaction, and returned it. + * + * Both failures are silent and neither is recoverable. An amount published that the protocol + * meant kept is on the chain for good. An amount hidden on an output a covenant will later + * read is money that cannot be spent, because a Simplicity program reads exact amounts + * through jets that cannot introspect a commitment. + */ +/** A module that takes every blinding key it is given and builds the output open anyway. */ +const ignoringKeys: ModuleBehaviour = (built) => ({ + changeBlinded: false, + outputs: built.outputs.map((output) => ({ ...output, blinded: false })), +}); + +/** A module that hides every output, including the ones a covenant has to read. */ +const hidingEverything: ModuleBehaviour = (built) => ({ + changeBlinded: true, + outputs: built.outputs.map((output) => ({ ...output, blinded: true })), +}); + +/** + * A module that builds every declared output as it was told and hides the change anyway. + * + * The direction that matters now. The wallet publishes a contract action's own change so the + * money returns in a form the next action can be funded from, and a module hiding it strands + * exactly that money — the next action can spend only what is already in the open, and nothing + * downstream of the module would say so. + */ +const hidingTheChange: ModuleBehaviour = (built) => ({ ...built, changeBlinded: true }); + +describe("what the transaction actually hides", () => { + test("is what the wallet decided: the covenant output open, the change published", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + // A covenant output can never hide what it carries, whatever a document says, and this + // is the wallet acting on that rather than stating it. + expect(recorded.outputs).toEqual([ + { asset: POLICY_ASSET, blinded: false, script: DERIVED_SCRIPT }, + ]); + // And the change is handed over without a blinding key, which the document did not ask + // for and this network's own default is against. It is the one place the wallet answers + // over the format, and it buys change the next action can actually be funded from. + expect(recorded.changeBlinded).toBe(false); + }); + + // The module this used to catch, kept because what it now proves is the change reaching the + // guard. It publishes every amount it is given a key for, and that is precisely what the + // wallet asked for here: the covenant output could never hide, and the change is published + // deliberately. An expectation that had not followed the decision would refuse this. + test("and a module that publishes everything is now exactly what the wallet asked for", async () => { + const { method, recorded } = subject(ISSUED, ignoringKeys); + + const result = await method(params(), context()); + + expect(result).toMatchObject({ broadcast: false }); + expect(recorded.changeBlinded).toBe(false); + }); + + test("and a module that hid the change the wallet published returns nothing", async () => { + const { method } = subject(ISSUED, hidingTheChange); + + const failure = await method(params(), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("hides the amount on the change"); + }); + + test("and a module that hid what the wallet left open returns nothing", async () => { + const { method } = subject(ISSUED, hidingEverything); + + const failure = await method(params(), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("hides the amount on p2pk_out"); + }); + + // The refusal happens after signing and before anything leaves, which is the only place + // it can: the bytes do not exist until the module has built them. + test("and nothing is broadcast when the guard refuses", async () => { + const { method, recorded } = subject(ISSUED, hidingTheChange); + + await method(params({ broadcast: true }), context()).catch(() => undefined); + + expect(recorded.broadcasts).toHaveLength(0); }); }); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 2a73f6c..9694d2e 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -2,6 +2,7 @@ import { SMPLX_COMPILER_VERSION } from "@humid/smplx-compiler"; import { createEsploraFeeRateReader, createEsploraTxOutReader, + guardBlindedOutputs, guardSpentInputs, isRefusal, type ManifestReview, @@ -15,6 +16,7 @@ import { } from "@humid/tx-manifest"; import type { KeyManagerState, UpdateKeyManagerState } from "@/core/key-manager/types"; +import { logger } from "@/core/logger"; import { createWalletMethod } from "@/core/wallet-methods/createWalletMethod"; import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError } from "@/core/wallet-rpc/errors"; import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; @@ -37,6 +39,16 @@ export type LiquidProcessCtContext = WalletRpcBaseContext & { export type LiquidProcessCtResult = { broadcast: boolean; + /** + * The deployment this action brought into existence, when it created one. + * + * Absent for every action that only spends what already exists. Returned rather than left + * for the caller to work out again, because half of these fields are functions of outputs + * the wallet chose — an asset id is derived from the output its issuing input spends — and + * a caller reconstructing them afterwards would be guessing which output that was. The + * deployment outlives the transaction; the transaction is where it can still be read. + */ + deployment?: Record<string, string>; feeSats: string; transactionHex: string; txid: string; @@ -170,6 +182,34 @@ export const createProcessLiquidConfidentialTransaction = ( }), execute: async ({ context, params, review }) => { const network = requireNetwork(context); + + /* + * What the signing module is about to be told about each covenant, beside what the + * review established about the same covenant from the chain. + * + * Written at warn so it survives a production build, because this is the seam where the + * two compiles can disagree and the disagreement only shows up as an execution failure + * after a person has approved. `covenantBuild` is the marker for which build is loaded: + * an extension without it in the log is an older copy, whatever the files on disk say. + */ + logger.warn("covenantBuild", { + action: review.action, + covenants: review.covenants.map((found) => ({ + address: found.address, + role: found.role, + utxoType: found.utxoType, + verified: found.verified, + })), + inputs: review.covenantInputs.map((covenant) => ({ + argumentsJson: covenant.argumentsJson, + extraLeavesJson: covenant.extraLeavesJson, + id: covenant.id, + includeDebugSymbols: covenant.includeDebugSymbols, + sourceBytes: covenant.source.length, + txid: covenant.txid, + vout: covenant.vout, + })), + }); const account = await dependencies.resolveAccount(context); const smplx = await dependencies.loadSmplx(); @@ -186,6 +226,15 @@ export const createProcessLiquidConfidentialTransaction = ( const signer = new smplx.WalletSigner(mnemonic, network); const builder = new smplx.TransactionBuilder(); + // A covenant branch guarded by a lock height reads the transaction's own + // locktime, and one that declares none satisfies no such branch. The review + // answers with where the chain is — the same thing every wallet writes there, + // and nothing about any protocol. Skipped where it read nothing, because an + // action whose covenants are not time-locked does not need one. + if (review.locktimeHeight !== undefined) { + builder.setLocktimeHeight(review.locktimeHeight); + } + try { // Which inputs create an asset, keyed by the output each one is derived // from. That outpoint is the only join both sides promise: the manifest @@ -232,27 +281,71 @@ export const createProcessLiquidConfidentialTransaction = ( } }; - // Covenant inputs first: the manifest's own input order is what a covenant - // introspects, and wallet inputs are the wallet's addition to it. - for (const covenant of review.covenantInputs) { - const key = outpointKey(covenant.txid, covenant.vout); + // In the order the review worked out, which is the document's wherever it + // states one. Every covenant used to be added first and the wallet's own + // after, which is one order among many: a covenant introspects positions, + // and a document stating one for an input the wallet supplies is saying + // that that order builds a transaction its contract will not run against. + for (const planned of review.inputOrder) { + const key = + planned.source === "covenant" + ? outpointKey(planned.covenant.txid, planned.covenant.vout) + : outpointKey(planned.utxo.txid, planned.utxo.vout); const issuance = issuing.get(key); - // The values the document states outright, which is how a covenant with - // more than one branch is told which to run. A signature is not among - // them: only the signer can make one, and naming it below is what asks - // for one. Passed as the compiler's own witness shape — a type and a - // literal, both text — because the compiler is what parses SimplicityHL. - const witness = witnessValuesJson(covenant.witnessValues); - if (issuance) { placed.add(key); - // The issuer contract is left unstated because a manifest declares - // none at any position, so both sides commit to nothing and each - // says so. - agreeOrRefuse( - issuance, - builder.addContractIssuanceInput( + } + + if (planned.source === "covenant") { + const { covenant } = planned; + // The values the document states outright, which is how a covenant + // with more than one branch is told which to run. A signature is not + // among them: only the signer can make one, and naming it below is + // what asks for one. Passed as the compiler's own witness shape — a + // type and a literal, both text — because the compiler is what + // parses SimplicityHL. + const witness = witnessValuesJson(covenant.witnessValues); + + if (issuance) { + logger.warn("covenantBuild:issue", { + id: covenant.id, + includeDebugSymbols: covenant.includeDebugSymbols, + leaves: covenant.extraLeavesJson, + }); + + // The issuer contract is left unstated because a manifest declares + // none at any position, so both sides commit to nothing and each + // says so. + agreeOrRefuse( + issuance, + builder.addContractIssuanceInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + witness, + covenant.signatureWitness, + issuance.assetAmountSats, + issuance.inflationAmountSats, + undefined, + sequenceFor(review, covenant.id), + covenant.extraLeavesJson, + covenant.includeDebugSymbols, + ), + ); + } else { + // The leaves and the mode go with the source and the parameters, because all + // four decide the script the covenant locks to. Sending the first two alone + // builds a different contract than the one the review checked against the + // chain, and the covenant refuses its own spend at execution. + logger.warn("covenantBuild:spend", { + id: covenant.id, + includeDebugSymbols: covenant.includeDebugSymbols, + leaves: covenant.extraLeavesJson, + }); + builder.addContractInput( covenant.txid, covenant.vout, covenant.txOutHex, @@ -260,32 +353,18 @@ export const createProcessLiquidConfidentialTransaction = ( covenant.argumentsJson, witness, covenant.signatureWitness, - issuance.assetAmountSats, - issuance.inflationAmountSats, - undefined, sequenceFor(review, covenant.id), - ), - ); - } else { - builder.addContractInput( - covenant.txid, - covenant.vout, - covenant.txOutHex, - covenant.source, - covenant.argumentsJson, - witness, - covenant.signatureWitness, - sequenceFor(review, covenant.id), - ); + covenant.extraLeavesJson, + covenant.includeDebugSymbols, + ); + } + + continue; } - } - for (const utxo of review.selected) { - const key = outpointKey(utxo.txid, utxo.vout); - const issuance = issuing.get(key); + const { utxo } = planned; if (issuance) { - placed.add(key); agreeOrRefuse( issuance, builder.addWalletIssuanceInput( @@ -327,10 +406,15 @@ export const createProcessLiquidConfidentialTransaction = ( // here: the builder has never read it, and an output built the wrong way is // one whose amount is published when the protocol meant it kept. for (const output of review.outputs) { + // Paid in the asset the review worked out for it, which is not always this + // account's policy asset and used to be assumed to be. An output carrying a + // protocol's own token, built in the network's asset instead, pays real + // money to a covenant expecting a token — and nothing downstream of here + // could tell. builder.addOutput( output.scriptPubKeyHex, output.sats, - account.rawPolicyAssetId, + output.asset, output.blinded ? signer.blindingPublicKey() : undefined, ); } @@ -378,8 +462,33 @@ export const createProcessLiquidConfidentialTransaction = ( ); } + // And what came back hides exactly what the document decided to hide. Handing the + // builder a blinding key is a request, not a result: whether it was applied is only + // visible in the bytes, where a hidden amount is a commitment and an open one is a + // number. Both directions are checked, because both are silent — an amount published + // that the protocol meant kept cannot be taken back, and an amount hidden on an + // output a covenant will later read is money nothing can spend. + const built = guardBlindedOutputs(signed.transactionHex, { + changeBlinded: review.changeBlinded, + outputs: review.outputs.map(({ blinded, id }) => ({ blinded, id })), + }); + + if (!built.ok) { + throw new WalletRpcInvalidParamsError( + built.reason, + { reject: "built-something-else" satisfies RejectToken }, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + // The deployment the action created, if it created one. Carried on both answers, + // because the caller that has to record it is the one that asked for the action and + // a transaction it did not broadcast is still one it may broadcast itself. + const deployment = + review.createdInstance === undefined ? {} : { deployment: review.createdInstance.fields }; + if (!params.broadcast) { - return { broadcast: false, ...signed }; + return { broadcast: false, ...deployment, ...signed }; } // LWK's Esplora client needs a `window` the service worker does not have, so the @@ -390,7 +499,7 @@ export const createProcessLiquidConfidentialTransaction = ( txHex: signed.transactionHex, }); - return { broadcast: true, ...signed, txid: sent.txid }; + return { broadcast: true, ...deployment, ...signed, txid: sent.txid }; }, id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, parse: parseRequest, @@ -428,10 +537,36 @@ export const createProcessLiquidConfidentialTransaction = ( contract.free(); } }, + // The other half of the same compiler, asked before a contract is built rather than + // after. A deployment wires most compile parameters to a name, which carries the + // format's own declared type; some it writes as a bare value, and those have no type + // at the position they are written. SimplicityHL declares one nowhere either — a + // parameter takes the type of the position it is used at, worked out by the type + // checker — so the compiler is the only thing that can say, and it can say it from + // the source alone, before there are any arguments to build. + contractParamTypes: (source) => JSON.parse(smplx.contractParameterTypes(source)), compilerVersion: SMPLX_COMPILER_VERSION, policyAsset: account.rawPolicyAssetId, - scriptPubKeyOf: ({ argumentsJson, source }) => - new smplx.Contract(source, argumentsJson).scriptPubKeyHex(network), + // The same compiler again, for the covenant hashes a document works out for itself. + // Everything a full compile is given, because a hash of a contract built any + // differently is the hash of a different contract — and a manifest stores that hash + // as a parameter of the covenant it then locks funds into. The leaves and the + // declared build mode were both absent here, so the hash was of a contract with an + // empty taproot tree built in whichever mode the module defaults to. + scriptPubKeyOf: ({ argumentsJson, extraLeavesJson, includeDebugSymbols, source }) => { + const contract = new smplx.Contract( + source, + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + ); + + try { + return contract.scriptPubKeyHex(network); + } finally { + contract.free(); + } + }, // Both lists, because only one of them can pay for this and the other one is why a // person is short. Selection spends the explicit ones and reports the hidden ones as // held back, which is the difference between "you do not have enough" and "you have @@ -440,12 +575,28 @@ export const createProcessLiquidConfidentialTransaction = ( ...context.walletBackend.getExplicitUtxos(account, account.rawPolicyAssetId), ...context.walletBackend.getUtxos(account, account.rawPolicyAssetId), ], + // The same two lists for any other asset the action turns out to move, asked for by + // id. Which assets those are is not knowable here — it is settled inside the review, + // after the document's lookups resolve against the deployment — so this is a + // question the runtime asks rather than an answer the wallet prepares. + holdingsOf: (asset) => [ + ...context.walletBackend.getExplicitUtxos(account, asset), + ...context.walletBackend.getUtxos(account, asset), + ], network, accountLabel: `${account.chain?.id ?? context.chain.id} account ${account.accountGroupIndex}`, + // The wallet's own scan rather than an endpoint: it has just synced, and a plain + // chain-tip route is not universal — the backend this wallet uses for Liquid + // testnet answers 404 to it, which is how a locktime came to be declared as zero. + readChainTip: async () => context.walletBackend.getTipHeight(account), readFeeRate: dependencies.readFeeRate(context.chain), readTxOut: dependencies.readTxOut(context.chain), + // The address this path can spend from rather than the one a person is shown for + // receiving. They differ as addresses are used, and an output paid back to this + // wallet at a rotating one is money the next action of the same protocol cannot + // find: the signing module derives one key, at the first external address. walletScriptPubKeyHex: await dependencies.scriptPubKeyHexOf( - context.walletBackend.getReceiveAddress(account).address, + context.walletBackend.getSigningAddress(account).address, ), }); diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/components/ReceiveView.tsx b/apps/extension/src/routes/App/pages/Home/pages/Receive/components/ReceiveView.tsx index a35e9f0..23ded65 100644 --- a/apps/extension/src/routes/App/pages/Home/pages/Receive/components/ReceiveView.tsx +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/components/ReceiveView.tsx @@ -1,61 +1,199 @@ -import { ArrowLeft01Icon, CheckmarkCircle02Icon, Copy01Icon } from "@hugeicons/core-free-icons"; +import { + ArrowLeft01Icon, + CheckmarkCircle02Icon, + Copy01Icon, + InformationCircleIcon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Link } from "@tanstack/react-router"; import QRCode from "react-qr-code"; +import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; import { cn } from "@/theme/utils.ts"; import { UiButtonVariants } from "@/ui/UiButton/base"; import { UiCopyButton } from "@/ui/UiCopyButton"; +import { UiScrollArea } from "@/ui/UiScrollArea"; +import { UiSpinner } from "@/ui/UiSpinner"; +import { UiTabs, UiTabsContent, UiTabsList, UiTabsTrigger } from "@/ui/UiTabs/base"; +import { UiTooltip, UiTooltipContent, UiTooltipProvider, UiTooltipTrigger } from "@/ui/UiTooltip"; + +const CONFIDENTIAL_TAB = "confidential"; +const UNCONFIDENTIAL_TAB = "unconfidential"; + +/** A label and the sentence that says what the value under it is for. */ +function LabelWithHint({ hint, label }: { hint: string; label: string }) { + return ( + <div className="flex items-center justify-center gap-1.5"> + <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> + {label} + </span> + <UiTooltip> + <UiTooltipTrigger + aria-label={`What ${label.toLowerCase()} is for`} + className="text-muted-foreground hover:text-foreground rounded-full transition-colors" + > + <HugeiconsIcon icon={InformationCircleIcon} size={14} /> + </UiTooltipTrigger> + <UiTooltipContent>{hint}</UiTooltipContent> + </UiTooltip> + </div> + ); +} + +/** One address as a QR, its own text, and a way to take it out. */ +function AddressPanel({ address, hint, label }: { address: string; hint: string; label: string }) { + return ( + <div className="flex flex-col items-center gap-4"> + <LabelWithHint hint={hint} label={label} /> + + <div className="rounded-xl border bg-white p-3"> + <QRCode value={address} size={176} bgColor="#ffffff" fgColor="#000000" /> + </div> + + <p className="text-muted-foreground max-w-full font-mono text-xs break-all">{address}</p> + + <UiCopyButton + className={cn(UiButtonVariants({ variant: "outline", size: "lg" }), "w-full")} + value={address} + > + {(copied) => ( + <> + <HugeiconsIcon icon={copied ? CheckmarkCircle02Icon : Copy01Icon} size={18} /> + {copied ? "Copied" : "Copy address"} + </> + )} + </UiCopyButton> + </div> + ); +} + +/** A value that is not an address: shown as text, with the same label and hint treatment. */ +function ValueRow({ hint, label, value }: { hint: string; label: string; value: string }) { + return ( + <div className="border-border/60 flex w-full flex-col items-center gap-2 border-t pt-4"> + <LabelWithHint hint={hint} label={label} /> + <p className="text-muted-foreground max-w-full font-mono text-xs break-all">{value}</p> + <UiCopyButton + className={cn(UiButtonVariants({ variant: "ghost", size: "sm" }))} + value={value} + > + {(copied) => ( + <> + <HugeiconsIcon icon={copied ? CheckmarkCircle02Icon : Copy01Icon} size={14} /> + {copied ? "Copied" : "Copy key"} + </> + )} + </UiCopyButton> + </div> + ); +} /** - * Presentational Receive screen: the account's receive address as a QR (always dark - * on white for scannability) plus a copyable string, for the selected account/chain. + * Presentational Receive screen. + * + * Two addresses rather than one, because this wallet has two and they are not + * interchangeable. The confidential one is blinded and moves along the descriptor; the + * unconfidential one is unblinded and fixed at the first external index, and is the only + * one a contract action can be funded from. Money paid to the first cannot pay for one, + * which is a thing to learn before a faucet payment rather than after. + * + * They are named for what they are rather than for what they are used for: the difference + * that decides which one to pay is blinding and derivation, and a reader who knows that + * needs no product word for it. + * + * The unconfidential address is read only once its tab is opened: answering loads the + * contract module, which is several megabytes, and most visits here only want an address. + * + * The page owns its own scroll, per the app shell's contract — the shell bounds the region + * and pins the footer beneath it, so anything taller than the popup has to scroll here. */ export function ReceiveView({ address, accountName, chainName, + contractIdentity, + contractError, + onContractOpened, }: { address: string; accountName: string; chainName: string; + contractIdentity?: LiquidContractIdentity; + contractError?: string; + onContractOpened?: () => void; }) { return ( - <div className="flex size-full min-h-0 flex-col"> - <header className="border-border/60 flex shrink-0 items-center gap-2 border-b px-2 py-2.5"> - <Link - to="/app" - aria-label="Back" - className="text-muted-foreground hover:bg-accent hover:text-foreground rounded-md p-1.5 transition-colors" - > - <HugeiconsIcon icon={ArrowLeft01Icon} size={18} /> - </Link> - <p className="text-sm font-semibold">Receive</p> - </header> + <UiTooltipProvider> + <div className="flex size-full min-h-0 flex-col overflow-hidden"> + <header className="border-border/60 flex shrink-0 items-center gap-2 border-b px-2 py-2.5"> + <Link + to="/app" + aria-label="Back" + className="text-muted-foreground hover:bg-accent hover:text-foreground rounded-md p-1.5 transition-colors" + > + <HugeiconsIcon icon={ArrowLeft01Icon} size={18} /> + </Link> + <p className="text-sm font-semibold">Receive</p> + </header> - <div className="flex flex-1 flex-col items-center gap-5 px-5 py-6 text-center"> - <p className="text-muted-foreground text-sm"> - {accountName} · {chainName} - </p> + <UiScrollArea className="min-h-0 flex-1"> + <div className="flex flex-col items-center gap-5 px-5 py-6 text-center"> + <p className="text-muted-foreground text-sm"> + {accountName} · {chainName} + </p> - <div className="rounded-xl border bg-white p-3"> - <QRCode value={address} size={176} bgColor="#ffffff" fgColor="#000000" /> - </div> + <UiTabs + defaultValue={CONFIDENTIAL_TAB} + className="w-full" + onValueChange={(value) => { + if (value === UNCONFIDENTIAL_TAB) { + onContractOpened?.(); + } + }} + > + <UiTabsList className="w-full"> + <UiTabsTrigger value={CONFIDENTIAL_TAB}>Confidential</UiTabsTrigger> + <UiTabsTrigger value={UNCONFIDENTIAL_TAB}>Unconfidential</UiTabsTrigger> + </UiTabsList> - <p className="text-muted-foreground max-w-full font-mono text-xs break-all">{address}</p> + <UiTabsContent value={CONFIDENTIAL_TAB} className="mt-5"> + <AddressPanel + address={address} + hint="Blinded, and derived one further along the descriptor each time you look. Amounts and assets paid to it are readable only with this wallet's blinding key." + label="Confidential address" + /> + </UiTabsContent> - <UiCopyButton - className={cn(UiButtonVariants({ variant: "outline", size: "lg" }), "w-full")} - value={address} - > - {(copied) => ( - <> - <HugeiconsIcon icon={copied ? CheckmarkCircle02Icon : Copy01Icon} size={18} /> - {copied ? "Copied" : "Copy address"} - </> - )} - </UiCopyButton> + <UiTabsContent value={UNCONFIDENTIAL_TAB} className="mt-5"> + {contractError === undefined ? null : ( + <p className="text-destructive text-xs">{contractError}</p> + )} + + {contractError === undefined && contractIdentity === undefined ? ( + <div className="flex justify-center py-10"> + <UiSpinner /> + </div> + ) : null} + + {contractIdentity === undefined ? null : ( + <div className="flex flex-col items-center gap-4"> + <AddressPanel + address={contractIdentity.address} + hint="Unblinded, and fixed at the first index of the external chain. Contract actions are funded from here and return their change here; amounts paid to it are public." + label="Unconfidential address" + /> + <ValueRow + hint="The x-only public key of the same index, which a covenant parameterised on this wallet's key takes. Public." + label="Signing public key (x-only)" + value={contractIdentity.schnorrPublicKey} + /> + </div> + )} + </UiTabsContent> + </UiTabs> + </div> + </UiScrollArea> </div> - </div> + </UiTooltipProvider> ); } diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/index.stories.tsx b/apps/extension/src/routes/App/pages/Home/pages/Receive/index.stories.tsx index 893744d..5ffe1bf 100644 --- a/apps/extension/src/routes/App/pages/Home/pages/Receive/index.stories.tsx +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/index.stories.tsx @@ -19,3 +19,22 @@ export const Default: Story = { chainName: "Liquid", }, }; + +/** The contract tab once the identity has been read: an address that never changes, and a key. */ +export const ContractIdentity: Story = { + args: { + ...Default.args, + contractIdentity: { + address: "tex1qxn3ufc3q78awd8nqqkmyk3sfxwmy4wgcnnrmqz", + schnorrPublicKey: "8f1a3c5e7b9d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a", + }, + }, +}; + +/** The contract tab when the background could not answer. */ +export const ContractIdentityFailed: Story = { + args: { + ...Default.args, + contractError: "Could not read the contract identity. Try again.", + }, +}; diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/index.tsx b/apps/extension/src/routes/App/pages/Home/pages/Receive/index.tsx index 49a4900..a497702 100644 --- a/apps/extension/src/routes/App/pages/Home/pages/Receive/index.tsx +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/index.tsx @@ -1,16 +1,25 @@ +import { useState } from "react"; + import { UiSpinner } from "@/ui/UiSpinner"; import { useHome } from "../../HomeContext"; import { ReceiveView } from "./components/ReceiveView"; +import { useContractIdentity } from "./useContractIdentity"; import { useReceiveAddress } from "./useReceiveAddress"; /** - * Receive tab: derives the account's receive address for the selected chain (LWK, on - * demand) and shows it as a QR + copyable string. Reached from the Receive action. + * Receive tab: derives the account's confidential address for the selected chain (LWK, on + * demand) and shows it as a QR + copyable string, beside the unconfidential address and the + * key contract actions are signed with. Reached from the Receive action. */ export function ReceivePage() { const { accountGroup, chain } = useHome(); const query = useReceiveAddress({ accountGroupId: accountGroup.id, chainId: chain.id }); + const [contractOpened, setContractOpened] = useState(false); + const identity = useContractIdentity({ + accountGroupId: accountGroup.id, + enabled: contractOpened, + }); if (query.isPending) { return ( @@ -36,6 +45,14 @@ export function ReceivePage() { address={query.data.address} accountName={accountGroup.name} chainName={chain.name} + contractIdentity={identity.data} + // What a person is told is chosen here rather than carried up from wherever it broke: + // the thrown message names a module, a network kind or a derivation path, and there is + // exactly one thing they can do about any failure of this read. + contractError={ + identity.isError ? "Could not read the contract identity. Try again." : undefined + } + onContractOpened={() => setContractOpened(true)} /> ); } diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts b/apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts new file mode 100644 index 0000000..2aae263 --- /dev/null +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; +import { readLiquidContractIdentity } from "@/core/chains/liquid/contractIdentityClient"; + +/** + * The address and key contract actions are signed with, for one account. + * + * Read on demand rather than with the page: the background loads the contract module to + * answer, which is several megabytes, and most visits to Receive only want an address. + */ +export function useContractIdentity(keys: { accountGroupId: AccountGroupId; enabled: boolean }) { + return useQuery({ + enabled: keys.enabled, + queryFn: () => readLiquidContractIdentity(keys.accountGroupId), + queryKey: ["contractIdentity", keys.accountGroupId], + // The identity is a function of the account's key and never changes under it. + staleTime: Infinity, + }); +} diff --git a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx index 748f8e1..1585c8b 100644 --- a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx +++ b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/AccountDetailView.tsx @@ -31,8 +31,6 @@ import { } from "@/ui/UiDialog"; import { UiScrollArea } from "@/ui/UiScrollArea"; -import { ContractIdentityRow } from "./ContractIdentityRow"; - type AccountDetailViewProps = { accountGroupId: AccountGroupId; accountName: string; @@ -113,7 +111,6 @@ export function AccountDetailView({ > <SettingsRowContent icon={PlugSocketIcon} label="Connected dapps" /> </Link> - <ContractIdentityRow accountGroupId={accountGroupId} /> <button className={cn(settingsRowClass, "text-destructive hover:bg-destructive/10")} onClick={() => setRemoveOpen(true)} diff --git a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx b/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx deleted file mode 100644 index 31ae2d5..0000000 --- a/apps/extension/src/routes/App/pages/Settings/pages/Account/components/ContractIdentityRow.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { CodeIcon } from "@hugeicons/core-free-icons"; -import { useState } from "react"; - -import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; -import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; -import { readLiquidContractIdentity } from "@/core/chains/liquid/contractIdentityClient"; -import { - settingsRowClass, - SettingsRowContent, -} from "@/routes/App/pages/Settings/components/SettingsRow"; -import { cn } from "@/theme/utils.ts"; -import { UiCopyButton } from "@/ui/UiCopyButton"; -import { UiSpinner } from "@/ui/UiSpinner"; - -/** One value, with what it is for and a way to take it out. */ -function Value({ hint, label, value }: { hint: string; label: string; value: string }) { - return ( - <div className="flex flex-col gap-1 px-3 py-2"> - <div className="flex items-center justify-between gap-2"> - <span className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> - {label} - </span> - <UiCopyButton value={value} /> - </div> - <span className="font-mono text-xs break-all">{value}</span> - <span className="text-muted-foreground text-xs">{hint}</span> - </div> - ); -} - -/** - * The address and key that contract actions are signed with. - * - * It is a separate row from the wallet's own address on purpose. A contract action is - * funded from this one unblinded address and returns change to it, so paying one from any - * other wallet address produces a transaction that cannot be signed. Saying so is more - * use than hiding it. - * - * The limit is this wallet's rather than the contract SDK's: the SDK takes a change target - * and a derivation path per input, and this wallet supplies one address and no paths. - * - * Nothing here is secret: an address anyone can pay and the public form of one key. It - * is read on demand rather than with the page because reading it loads the contract - * module, which is several megabytes. - */ -export function ContractIdentityRow({ accountGroupId }: { accountGroupId: AccountGroupId }) { - const [identity, setIdentity] = useState<LiquidContractIdentity>(); - const [error, setError] = useState<string>(); - const [loading, setLoading] = useState(false); - - const read = async () => { - setLoading(true); - setError(undefined); - - try { - setIdentity(await readLiquidContractIdentity(accountGroupId)); - } catch { - // What a person is told is chosen here, not carried up from wherever it broke. The - // thrown error's own message is written for whoever is debugging the wallet: it may - // name a module, a network kind or a derivation path, none of which this reader can - // act on, and it changes whenever the code below changes. There is exactly one thing - // they can do about any failure of this read, so that is what it says. - setError("Could not read the contract identity. Try again."); - } finally { - setLoading(false); - } - }; - - return ( - <> - <button - className={cn(settingsRowClass, "hover:bg-accent")} - disabled={loading} - onClick={() => void read()} - type="button" - > - <SettingsRowContent - icon={CodeIcon} - label="Contract signing identity" - trailing={loading ? <UiSpinner /> : undefined} - /> - </button> - - {error === undefined ? null : <p className="text-destructive px-3 py-2 text-xs">{error}</p>} - - {identity === undefined ? null : ( - <div className="bg-muted/40 mx-3 my-1 rounded-lg py-1"> - <Value - hint="Fund contract actions from here, unblinded. Change returns to this address." - label="Contract address" - value={identity.address} - /> - <Value - hint="Lock a covenant to this wallet with this key — a protocol parameter naming the signer takes it." - label="Contract public key (x-only)" - value={identity.schnorrPublicKey} - /> - <p className="text-muted-foreground px-3 pt-1 pb-2 text-xs"> - One key signs every contract action, so only what sits at this address can be spent by - one. This is narrower than the wallet's own balance. - </p> - </div> - )} - </> - ); -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 511b91c..68b3764 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,13 +2,14 @@ import { ChevronLeftIcon } from "lucide-react"; import { useState } from "react"; import Dashboard from "@/app/dashboard"; +import FormatSupport from "@/app/format"; import Home from "@/app/home"; import ManifestInspector from "@/app/manifest"; import { Button } from "@/components/ui/button"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -type View = "developer" | "home" | "manifest"; +type View = "developer" | "format" | "home" | "manifest"; export function App() { const [view, setView] = useState<View>("home"); @@ -20,6 +21,7 @@ export function App() { return ( <Home onOpenDeveloper={() => setView("developer")} + onOpenFormatSupport={() => setView("format")} onOpenManifestInspector={() => setView("manifest")} /> ); @@ -33,7 +35,17 @@ export function App() { Back to app </Button> </div> - {view === "developer" ? <Dashboard /> : <ManifestInspector />} + {(() => { + if (view === "developer") { + return <Dashboard />; + } + + if (view === "format") { + return <FormatSupport />; + } + + return <ManifestInspector />; + })()} </div> ); })()} diff --git a/apps/web/src/app/format/index.test.tsx b/apps/web/src/app/format/index.test.tsx new file mode 100644 index 0000000..9a36ee0 --- /dev/null +++ b/apps/web/src/app/format/index.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; + +import { describeRegistry } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import FormatSupport from "./index"; +import { WHERE_IT_SITS } from "./positions"; + +// AC-07 and AC-08. The page's whole content is the runtime's own construct table, so what is +// checked here is that all of it arrives, that what the wallet cannot do leads, and that every +// gap carries its reason — the part no document can ever show, because no published protocol +// uses any of the seven. + +function render(): string { + return renderToStaticMarkup(<FormatSupport />); +} + +describe("what this wallet does not implement", () => { + test("leads with it, before anything the wallet does read", () => { + const html = render(); + + expect(html.indexOf("Not implemented")).toBeLessThan( + html.indexOf("Read, and it changes what gets signed"), + ); + }); + + test("names every construct the runtime does not act on, with its reason", () => { + const html = render(); + + for (const entry of describeRegistry().filter((candidate) => candidate.reason !== undefined)) { + expect(html).toContain(entry.key); + expect(html).toContain(escaped(entry.reason ?? "")); + } + }); + + // The count is what an engineer came for and the one thing that must not be written down by + // hand: a sentence saying "seven" survives an eighth being added. + test("counts what is missing from the table rather than from a sentence", () => { + const unimplemented = describeRegistry().filter((entry) => entry.state === "unimplemented"); + + expect(render()).toContain(`>${unimplemented.length}</span>`); + }); +}); + +describe("the whole table, not a sample of it", () => { + test("renders every construct the runtime registers", () => { + const html = render(); + + for (const entry of describeRegistry()) { + expect(html).toContain(entry.key); + } + }); + + test("says how much of the format this is, counted rather than stated", () => { + const entries = describeRegistry(); + const positioned = entries.filter((entry) => entry.site !== undefined); + + expect(render()).toContain(`${positioned.length} fields at`); + }); + + test("says where each one sits in words a reader can use", () => { + const html = render(); + + for (const where of Object.values(WHERE_IT_SITS)) { + expect(html).toContain(where); + } + }); +}); + +describe("the page stands alone", () => { + // AC-07's other half and AC-10. It holds no wallet context and reads no document: every other + // surface in this app reads a wallet context, and reading a missing one would throw. + test("renders with no wallet, no provider, no network and nothing pasted", () => { + const html = render(); + + expect(html).toContain("What this wallet reads of the format"); + expect(html).not.toContain("<textarea"); + }); +}); + +function escaped(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/apps/web/src/app/format/index.tsx b/apps/web/src/app/format/index.tsx new file mode 100644 index 0000000..744d297 --- /dev/null +++ b/apps/web/src/app/format/index.tsx @@ -0,0 +1,125 @@ +import { type ConstructRegistryEntry, describeRegistry } from "@humid/tx-manifest"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +import { WHERE_IT_SITS } from "./positions"; + +/** + * What this wallet reads of the transaction-manifest format, and what it does not. + * + * The manifest page answers a question about one document. This one answers a question no + * document can: a construct nobody has published is invisible in every document there is, and + * all seven that the format defines and this wallet does not implement are in that position. + * Every published protocol therefore inspects clean while the seven stand, which is why this + * is a page rather than a section beside a box someone pastes into. + * + * It reads nothing from that page and nothing from anywhere else. Its whole content is the + * runtime's own construct table, so it cannot describe a wallet that differs from the one + * that runs. + */ +export default function FormatSupport() { + const entries = describeRegistry(); + + return ( + <div className="mx-auto flex min-h-svh w-full max-w-4xl flex-col gap-6 p-4 md:p-6"> + <Card> + <CardHeader> + <CardTitle>What this wallet reads of the format</CardTitle> + <CardDescription> + Every field the transaction-manifest format defines, against what this wallet does with + it. Nothing here depends on a document — it is the same table the wallet decides by, + printed. + </CardDescription> + </CardHeader> + <CardContent> + <p className="text-muted-foreground text-sm">{summaryOf(entries)}</p> + </CardContent> + </Card> + + <Section + title="Not implemented" + description="The format defines these and this wallet does not act on them. A document using one is refused rather than read past." + entries={entries.filter((entry) => entry.state === "unimplemented")} + /> + <Section + title="Deliberately read by nothing" + description="Known, and read by nothing here or in the reference implementation. A document using one is not refused, because being wrong about it cannot change what gets signed." + entries={entries.filter((entry) => entry.state === "never-read")} + /> + <Section + title="Read, and shown to a person" + description="Read and put in front of whoever approves the action. None of it decides a value." + entries={entries.filter((entry) => entry.state === "shown")} + /> + <Section + title="Read, and it changes what gets signed" + description="The part of the format this wallet acts on." + entries={entries.filter((entry) => entry.state === "acted-on")} + /> + </div> + ); +} + +function Section({ + description, + entries, + title, +}: { + description: string; + entries: ConstructRegistryEntry[]; + title: string; +}) { + if (entries.length === 0) { + return null; + } + + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + {title} + <Badge variant="secondary">{entries.length}</Badge> + </CardTitle> + <CardDescription>{description}</CardDescription> + </CardHeader> + <CardContent> + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <tbody> + {entries.map((entry) => ( + <tr + key={`${entry.site ?? "everywhere"}/${entry.key}`} + className="border-border/50 border-b align-top" + > + <td className="py-2 pr-4 font-mono whitespace-nowrap">{entry.key}</td> + <td className="text-muted-foreground py-2 pr-4 whitespace-nowrap"> + {WHERE_IT_SITS[entry.site ?? "everywhere"]} + </td> + <td className="text-muted-foreground py-2">{entry.reason}</td> + </tr> + ))} + </tbody> + </table> + </div> + </CardContent> + </Card> + ); +} + +/** + * How much of the format this is, said before any of it is read. + * + * Counted from the table rather than written down, so the sentence cannot fall behind the + * thing it describes — which is the same reason this page exists at all. + */ +function summaryOf(entries: ConstructRegistryEntry[]): string { + const positioned = entries.filter((entry) => entry.site !== undefined); + const kinds = new Set(positioned.map((entry) => entry.site)).size; + const everywhere = entries.length - positioned.length; + + return ( + `${positioned.length} fields at ${kinds} kinds of position, plus ${everywhere} that any ` + + "JSON document may carry anywhere. Each one this wallet does not act on says why." + ); +} diff --git a/apps/web/src/app/format/positions.ts b/apps/web/src/app/format/positions.ts new file mode 100644 index 0000000..5fb0f48 --- /dev/null +++ b/apps/web/src/app/format/positions.ts @@ -0,0 +1,26 @@ +import type { ConstructSiteKind } from "@humid/tx-manifest"; + +/** + * Where a field sits, in the words a person would use for it. + * + * A translation and not a claim: the runtime keys its table by these names and this says the + * same thing in English, so nothing here can be true while the runtime says otherwise. It is + * typed against the runtime's own set, so a kind of position added there and forgotten here + * fails to compile rather than rendering a key nobody can read. + * + * `everywhere` is not one of the runtime's kinds. It stands for the two keys any JSON document + * may carry at any depth, which the runtime answers once rather than listing at every position. + */ +export const WHERE_IT_SITS: Record<ConstructSiteKind | "everywhere", string> = { + action: "on an action", + everywhere: "anywhere", + input: "on an input", + manifest: "on the document", + output: "on an output", + param: "on a parameter", + script: "on a contract", + ui: "in display metadata", + utxoType: "on a kind of holding", + validation: "on a rule", + witness: "on a witness", +}; diff --git a/apps/web/src/app/home/index.tsx b/apps/web/src/app/home/index.tsx index 71ec357..dd115fb 100644 --- a/apps/web/src/app/home/index.tsx +++ b/apps/web/src/app/home/index.tsx @@ -10,9 +10,11 @@ import { HomeActions } from "./components/HomeActions"; */ export default function Home({ onOpenDeveloper, + onOpenFormatSupport, onOpenManifestInspector, }: { onOpenDeveloper: () => void; + onOpenFormatSupport: () => void; onOpenManifestInspector: () => void; }) { const { hasProvider, isConnected } = useHumidContext(); @@ -47,6 +49,14 @@ export default function Home({ > Manifest inspector </Button> + <Button + variant="ghost" + size="sm" + className="text-muted-foreground text-xs" + onClick={onOpenFormatSupport} + > + Format support + </Button> </div> </div> ); diff --git a/apps/web/src/app/manifest/components/ConstructTable.test.tsx b/apps/web/src/app/manifest/components/ConstructTable.test.tsx index 838e21f..1297716 100644 --- a/apps/web/src/app/manifest/components/ConstructTable.test.tsx +++ b/apps/web/src/app/manifest/components/ConstructTable.test.tsx @@ -1,16 +1,22 @@ import { describe, expect, test } from "bun:test"; -import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; +import type { ConstructReport, ConstructSiteKind, ConstructState } from "@humid/tx-manifest"; import { renderToStaticMarkup } from "react-dom/server"; import { ConstructTable } from "./ConstructTable"; -// AC-03 at the surface. The five states and the positions come from the package and are -// tested there; what is checked here is that a reader is shown the state, the field, where it -// sits, and — the part a state name alone does not carry — what that state means for them. +// AC-04 and AC-05 at the surface. The five states and the positions come from the package and +// are tested there; what is checked here is that a reader is shown the state, the field, where +// it sits, how many places that is, and — the part a state name alone does not carry — what +// that state means for them. -function report(state: ConstructState, key: string = state, at = "manifest"): ConstructReport { - return { at, key, state }; +function report( + state: ConstructState, + key: string = state, + at = "manifest", + site: ConstructSiteKind = "manifest", +): ConstructReport { + return { at, key, site, state }; } function render(constructs: ConstructReport[]): string { @@ -19,7 +25,7 @@ function render(constructs: ConstructReport[]): string { describe("what a reader is told about each field", () => { test("shows the field, where it sits, and its state", () => { - const html = render([report("unimplemented", "args", "action Pay")]); + const html = render([report("unimplemented", "args", "action Pay", "action")]); expect(html).toContain("args"); expect(html).toContain("action Pay"); @@ -41,17 +47,68 @@ describe("what a reader is told about each field", () => { expect(html).not.toContain("<table"); }); - test("two fields at different positions are both shown, not collapsed by name", () => { + test("draws no heading for a state this document does not use", () => { + expect(render([report("acted-on")])).not.toContain("never-read"); + }); +}); + +describe("a key that recurs draws one row", () => { + test("counts the positions instead of repeating the field", () => { + const html = render([ + report("unimplemented", "args", "action Pay", "action"), + report("unimplemented", "args", "action Refund", "action"), + report("unimplemented", "args", "action Close", "action"), + ]); + + expect(html.match(/args/g)).toHaveLength(1); + expect(html).toContain("3 positions"); + }); + + test("still names every position, so nothing is only counted", () => { const html = render([ - report("acted-on", "description", "action Pay"), - report("acted-on", "description", "action Receive"), + report("unimplemented", "args", "action Pay", "action"), + report("unimplemented", "args", "action Refund", "action"), ]); expect(html).toContain("action Pay"); - expect(html).toContain("action Receive"); + expect(html).toContain("action Refund"); }); - test("draws no heading for a state this document does not use", () => { - expect(render([report("acted-on")])).not.toContain("never-read"); + test("names the one position outright when a field sits at exactly one", () => { + const html = render([report("unimplemented", "args", "action Pay", "action")]); + + expect(html).toContain("action Pay"); + expect(html).not.toContain("1 positions"); + }); +}); + +describe("what is working opens closed", () => { + // AC-05. Not hidden and not dropped: the count is visible without clicking and the rows are + // one click away. What is removed is meeting six hundred rows that say a field works before + // reaching the nine that say anything else. + test("puts the states that mean nothing is wrong behind a disclosure", () => { + const html = render([report("acted-on", "chain"), report("shown", "description")]); + + expect(html.match(/<details/g)).toHaveLength(2); + }); + + test("leaves anything unrecognised or unimplemented open", () => { + const html = render([ + report("unrecognised", "wat"), + report("unimplemented", "args", "action Pay", "action"), + report("never-read", "source"), + ]); + + expect(html).not.toContain("<details"); + }); + + test("says how much a closed group holds before it is opened", () => { + const html = render([ + report("acted-on", "chain"), + report("acted-on", "amount_sat", "action Pay / output a", "output"), + report("acted-on", "amount_sat", "action Pay / output b", "output"), + ]); + + expect(html).toContain("2 fields, at 3 positions"); }); }); diff --git a/apps/web/src/app/manifest/components/ConstructTable.tsx b/apps/web/src/app/manifest/components/ConstructTable.tsx index 3e673d5..e38c133 100644 --- a/apps/web/src/app/manifest/components/ConstructTable.tsx +++ b/apps/web/src/app/manifest/components/ConstructTable.tsx @@ -2,7 +2,7 @@ import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; import { Badge } from "@/components/ui/badge"; -import { groupByState } from "./groupByState"; +import { type ConstructGroup, groupByState } from "./groupByState"; /** * What each state means, in the words a protocol author would use. @@ -29,35 +29,106 @@ const MEANING: Record<ConstructState, { badge: BadgeVariant; sentence: string }> type BadgeVariant = "default" | "destructive" | "ghost" | "secondary"; +/** + * Every construct this document declares, once each, against what the runtime does with it. + * + * One row per construct rather than per position, because a key genuinely recurs — 94 places + * in the deployed lending protocol — and a row per place is 620 rows saying 41 things. The + * places are still all here, under the row that counts them. + * + * The two states that mean nothing is wrong open collapsed. That is the whole of what was + * unreadable: not that the information was present, but that 611 rows of "this field works" + * came before the nine that said anything else. + */ export function ConstructTable({ constructs }: { constructs: ConstructReport[] }) { if (constructs.length === 0) { return <p className="text-muted-foreground text-sm">This document declares no fields.</p>; } - const grouped = groupByState(constructs); - return ( <div className="flex flex-col gap-6"> - {grouped.map((group) => ( - <section key={group.state} className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> - <Badge variant={MEANING[group.state].badge}>{group.state}</Badge> - <span className="text-muted-foreground text-xs">{MEANING[group.state].sentence}</span> - </div> - <div className="overflow-x-auto"> - <table className="w-full text-sm"> - <tbody> - {group.entries.map((report) => ( - <tr key={`${report.at}/${report.key}`} className="border-border/50 border-b"> - <td className="py-1 pr-4 font-mono">{report.key}</td> - <td className="text-muted-foreground py-1">{report.at}</td> - </tr> - ))} - </tbody> - </table> - </div> - </section> + {groupByState(constructs).map((group) => ( + <Group key={group.state} group={group} /> ))} </div> ); } + +function Group({ group }: { group: ConstructGroup }) { + const heading = ( + <div className="flex flex-wrap items-center gap-2"> + <Badge variant={MEANING[group.state].badge}>{group.state}</Badge> + <span className="text-muted-foreground text-xs">{MEANING[group.state].sentence}</span> + <span className="text-muted-foreground text-xs">{countOf(group)}</span> + </div> + ); + + if (!group.nothingWrong) { + return ( + <section className="flex flex-col gap-2"> + {heading} + <Rows group={group} /> + </section> + ); + } + + return ( + <details className="flex flex-col gap-2"> + <summary className="cursor-pointer list-none">{heading}</summary> + <div className="pt-2"> + <Rows group={group} /> + </div> + </details> + ); +} + +function Rows({ group }: { group: ConstructGroup }) { + return ( + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <tbody> + {group.rows.map((row) => ( + <tr key={`${row.site}/${row.key}`} className="border-border/50 border-b align-top"> + <td className="py-1 pr-4 font-mono">{row.key}</td> + <td className="text-muted-foreground py-1"> + {whereOf(row)} + {row.at.length > 1 && <span className="block text-xs">{row.at.join(" · ")}</span>} + </td> + </tr> + ))} + </tbody> + </table> + </div> + ); +} + +/** + * Where one construct sits, said as a place when there is one and as a count when there are + * many. The places themselves follow underneath either way, so the count is a headline rather + * than a substitute. + */ +function whereOf(row: { at: string[] }): string { + if (row.at.length === 1) { + return row.at[0] ?? ""; + } + + return `${row.at.length} positions`; +} + +/** + * How much this group holds, said before it is opened. + * + * A collapsed group whose size is unknown is a page hiding something; a collapsed group that + * says how many constructs and how many positions it holds is a page that has already + * answered the only question the reader had about it. + */ +function countOf(group: ConstructGroup): string { + const positions = group.rows.reduce((total, row) => total + row.at.length, 0); + const constructs = `${group.rows.length} ${group.rows.length === 1 ? "field" : "fields"}`; + + if (positions === group.rows.length) { + return constructs; + } + + return `${constructs}, at ${positions} positions`; +} diff --git a/apps/web/src/app/manifest/components/RefusalPanel.test.tsx b/apps/web/src/app/manifest/components/RefusalPanel.test.tsx deleted file mode 100644 index 3a771ea..0000000 --- a/apps/web/src/app/manifest/components/RefusalPanel.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { renderToStaticMarkup } from "react-dom/server"; - -import { RefusalPanel } from "./RefusalPanel"; - -// AC-04 and AC-05, at the only place they can be checked: the text a reader actually meets. -// Rendered to a string rather than to a DOM, because this repository has no DOM in its tests -// and react-dom is already here — the assertions below are about words on a screen, and a -// string carries those. - -function render(inspection: Parameters<typeof RefusalPanel>[0]["inspection"]): string { - return renderToStaticMarkup(<RefusalPanel inspection={inspection} />); -} - -const NOTHING_ASKED: Pick< - Parameters<typeof RefusalPanel>[0]["inspection"], - "constructs" | "partial" | "skipped" | "unreachable" -> = { - constructs: [], - partial: [], - skipped: [], - unreachable: ["covenant-mismatch", "shortfall", "no-fee-rate"], -}; - -describe("what a reader is told about refusal", () => { - test("prints the refusal's stable token and its sentence", () => { - const html = render({ - ...NOTHING_ASKED, - refusal: { reason: 'This protocol is for "bitcoin".', reject: "foreign-chain" }, - }); - - expect(html).toContain("foreign-chain"); - expect(html).toContain("bitcoin"); - }); - - // The single most misreadable thing on the page. A document can be flawless in every way a - // document can be judged and still be unbuildable for want of money. - test("never lets no-refusal read as a promise that the wallet would build", () => { - const html = render({ ...NOTHING_ASKED, refusal: undefined }); - - expect(html).toContain("No refusal that a document alone can decide"); - expect(html).toContain("not a statement that the wallet would build"); - }); - - test("names the unreachable checks whether or not a refusal was found", () => { - for (const refusal of [undefined, { reason: "…", reject: "foreign-chain" as const }]) { - const html = render({ ...NOTHING_ASKED, refusal }); - - expect(html).toContain("covenant-mismatch"); - expect(html).toContain("shortfall"); - expect(html).toContain("no-fee-rate"); - expect(html).toContain("Not checkable from a document at all"); - } - }); - - test("says why the unreachable ones are unreachable, not merely that they are", () => { - const html = render({ ...NOTHING_ASKED, refusal: undefined }); - - expect(html).toContain("money"); - expect(html).toContain("chain read"); - }); - - test("separates checks it could have made from checks nothing could", () => { - const html = render({ - constructs: [], - partial: [], - refusal: undefined, - skipped: ["foreign-compiler"], - unreachable: ["shortfall"], - }); - - expect(html).toContain("Not checked, because this page was not told what it needs"); - expect(html).toContain("foreign-compiler"); - expect(html).toContain("Not checkable from a document at all"); - }); - - // AC-04. Between skipped and done there is a third answer, and the page has to carry it or - // a check that read one of its two places is read as one that passed. - test("keeps a half-answered check apart from both a skipped one and a passed one", () => { - const html = render({ - ...NOTHING_ASKED, - partial: [{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }], - refusal: undefined, - }); - - expect(html).toContain("Checked in one of the two places that decide it"); - expect(html).toContain("./p2pk.simf"); - expect(html).not.toContain("Not checked, because"); - }); - - test("says which sources went unread rather than that some did", () => { - const html = render({ - ...NOTHING_ASKED, - partial: [{ reject: "foreign-compiler", unread: ["./lending.simf", "./script_auth.simf"] }], - refusal: undefined, - }); - - expect(html).toContain("./lending.simf"); - expect(html).toContain("./script_auth.simf"); - }); - - test("says nothing about a half-answered check when every check was answered in full", () => { - expect(render({ ...NOTHING_ASKED, refusal: undefined })).not.toContain("Checked in one of"); - }); - - // Found by using this page on the five published protocols: each refused on one decorative - // field and read as hopeless, when the field table below said three fixable gaps. - test("says how many fields would refuse, not only which one the wallet names", () => { - const html = render({ - ...NOTHING_ASKED, - constructs: [ - { at: "manifest", key: "$schema", state: "unrecognised" }, - { at: "manifest", key: "contract_templates", state: "unrecognised" }, - { at: "manifest", key: "simplicity_hl", state: "unrecognised" }, - { at: "manifest", key: "description", state: "shown" }, - ], - refusal: { reason: "…", reject: "unrecognised-construct" }, - }); - - expect(html).toContain("3 fields in this document would refuse"); - expect(html).toContain("The other 2"); - }); - - test("does not count when the wallet's one refusal is the whole of it", () => { - const html = render({ - ...NOTHING_ASKED, - constructs: [{ at: "manifest", key: "$schema", state: "unrecognised" }], - refusal: { reason: "…", reject: "unrecognised-construct" }, - }); - - expect(html).not.toContain("would refuse, and the wallet names"); - }); - - test("says nothing about skipped checks when none were skipped", () => { - const html = render({ ...NOTHING_ASKED, refusal: undefined }); - - expect(html).not.toContain("Not checked, because"); - }); -}); diff --git a/apps/web/src/app/manifest/components/RefusalPanel.tsx b/apps/web/src/app/manifest/components/RefusalPanel.tsx deleted file mode 100644 index ae4df6c..0000000 --- a/apps/web/src/app/manifest/components/RefusalPanel.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import type { ManifestInspection, RejectToken } from "@humid/tx-manifest"; - -import { Badge } from "@/components/ui/badge"; - -/** - * The refusal, and — always beside it — what was never asked. - * - * The absence of a refusal here is the most misreadable thing on this page. A document can - * be flawless in every way a document can be judged and still be unbuildable for want of - * money, a fee rate, or the covenant actually being where the state file says. So the - * unreached checks are not a footnote: they are rendered in the same panel, at the same - * weight, whether or not a refusal was found. - * - * The second most misreadable thing was found by using this page on real documents. The - * runtime returns one refusal and does so deliberately: a person deciding whether to trust a - * site is not helped by a list of eleven field names. But a developer diagnosing coverage is - * misled by it — five published protocols each refused on one decorative field, and each read - * as hopeless when the truth was three fixable gaps. Saying how many fields are in that class - * is not disagreeing with the runtime's choice; it is this page declining to let one stand in - * for all of them. - */ -export function RefusalPanel({ - inspection, -}: { - inspection: Pick< - ManifestInspection, - "constructs" | "partial" | "refusal" | "skipped" | "unreachable" - >; -}) { - const wouldRefuse = inspection.constructs.filter( - (report) => report.state === "unimplemented" || report.state === "unrecognised", - ); - return ( - <div className="flex flex-col gap-4"> - {(() => { - if (!inspection.refusal) { - return ( - <p className="text-sm"> - No refusal that a document alone can decide. This is not a statement that the wallet - would build an action from it. - </p> - ); - } - - return ( - <div className="flex flex-col gap-2"> - <Badge variant="destructive" className="font-mono"> - {inspection.refusal.reject} - </Badge> - <p className="text-sm">{inspection.refusal.reason}</p> - {wouldRefuse.length > 1 && ( - <p className="text-muted-foreground text-xs"> - {wouldRefuse.length} fields in this document would refuse, and the wallet names the - first. The other {wouldRefuse.length - 1} are in the field table below, under - unrecognised and unimplemented — fixing this one uncovers them rather than - finishing. - </p> - )} - </div> - ); - })()} - - {inspection.skipped.length > 0 && ( - <Unasked - heading="Not checked, because this page was not told what it needs" - explanations={whyUnasked(inspection.skipped)} - tokens={inspection.skipped} - /> - )} - - {inspection.partial.length > 0 && ( - <section className="flex flex-col gap-2"> - <h3 className="text-sm font-medium">Checked in one of the two places that decide it</h3> - <p className="text-muted-foreground text-xs"> - A compiler version is declared twice: by the document, and by a directive inside each - contract source. The document's own declaration was checked. These sources were not - read, so what they ask for is unknown — which is not the same as agreeing. - </p> - {inspection.partial.map((check) => ( - <div key={check.reject} className="flex flex-wrap items-center gap-1"> - <Badge variant="outline" className="font-mono"> - {check.reject} - </Badge> - {check.unread.map((path) => ( - <code key={path} className="text-muted-foreground font-mono text-xs"> - {path} - </code> - ))} - </div> - ))} - </section> - )} - - <Unasked - heading="Not checkable from a document at all" - explanations={[ - "Each of these is decided against money, a chain read, a fee rate or a filled request. Reading a document establishes nothing about any of them.", - ]} - tokens={inspection.unreachable} - /> - </div> - ); -} - -/** - * Why each unrun check was not run, in the reader's own terms. - * - * One sentence per missing input rather than per check, because two of them are missing the - * same thing and a person reading this is deciding what to do about it. Where the answer is - * theirs to give, the sentence says so — an explanation that only states what is absent - * leaves the page looking broken rather than waiting. - */ -function whyUnasked(skipped: readonly RejectToken[]): string[] { - const explanations: string[] = []; - - if (skipped.includes("foreign-compiler")) { - explanations.push( - "The compiler check needs the single SimplicityHL version a wallet ships, and the reader was given none.", - ); - } - - if (skipped.includes("foreign-asset") || skipped.includes("unbuildable-utxo-type")) { - explanations.push( - "The asset checks need the asset the network charges in, and no network is chosen. Choose one above and they run.", - ); - } - - return explanations; -} - -function Unasked({ - explanations, - heading, - tokens, -}: { - explanations: readonly string[]; - heading: string; - tokens: readonly string[]; -}) { - return ( - <section className="flex flex-col gap-2"> - <h3 className="text-sm font-medium">{heading}</h3> - {explanations.map((explanation) => ( - <p key={explanation} className="text-muted-foreground text-xs"> - {explanation} - </p> - ))} - <div className="flex flex-wrap gap-1"> - {tokens.map((token) => ( - <Badge key={token} variant="outline" className="font-mono"> - {token} - </Badge> - ))} - </div> - </section> - ); -} diff --git a/apps/web/src/app/manifest/components/RewriteList.test.tsx b/apps/web/src/app/manifest/components/RewriteList.test.tsx index c915fce..dd13138 100644 --- a/apps/web/src/app/manifest/components/RewriteList.test.tsx +++ b/apps/web/src/app/manifest/components/RewriteList.test.tsx @@ -5,10 +5,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { RewriteList } from "./RewriteList"; -// AC-02 at the surface. The criterion asks for three things per rewrite — where, the name it -// now carries, the name it had — and for a clean document to say so rather than show nothing, -// because an empty region and "nothing needed rewriting" look identical and mean different -// things. +// AC-06's second half. Three things per rewrite — where, the name it now carries, the name it +// had — now sitting with the fields rather than in a region of their own. The statement that a +// document needed no rewriting moved to the verdict, so this renders nothing at all for a clean +// document: the page says it once, where the answer is. function render(rewrites: NormalisationNote[]): string { return renderToStaticMarkup(<RewriteList rewrites={rewrites} />); @@ -23,12 +23,10 @@ describe("what a reader is told about older spellings", () => { expect(html).toContain("action Pay"); }); - test("a clean document says nothing was rewritten rather than showing an empty region", () => { - const html = render([]); - - expect(html).toContain("Nothing was rewritten"); - expect(html).toContain("current spelling"); - expect(html).not.toContain("<table"); + // The verdict carries this now, in one sentence beside the answer it belongs to. A second + // statement here would be the page saying the same thing twice at different weights. + test("a clean document draws nothing here at all", () => { + expect(render([])).toBe(""); }); test("says what a rewrite means: the document is from an earlier generation", () => { diff --git a/apps/web/src/app/manifest/components/RewriteList.tsx b/apps/web/src/app/manifest/components/RewriteList.tsx index 8627580..361a9da 100644 --- a/apps/web/src/app/manifest/components/RewriteList.tsx +++ b/apps/web/src/app/manifest/components/RewriteList.tsx @@ -1,20 +1,29 @@ import type { NormalisationNote } from "@humid/tx-manifest"; +/** + * The renamings themselves, against the fields they renamed. + * + * This used to be a region of its own, and it was the least readable thing on the page: it + * reported, at the weight of a finding, that the reader had accepted an older spelling and + * carried on — which changed nothing about the answer. What is worth knowing from it is one + * sentence and lives in the verdict now. What is left is a lookup, for someone who has the + * document open and wants to know which of its keys the runtime knows by another name. + * + * Nothing is rendered when nothing was renamed. The verdict has already said so, and a second + * statement of it here would be the page repeating itself at the reader. + */ export function RewriteList({ rewrites }: { rewrites: NormalisationNote[] }) { if (rewrites.length === 0) { - return ( - <p className="text-muted-foreground text-sm"> - Nothing was rewritten. This document is written in the format's current spelling. - </p> - ); + return null; } return ( - <div className="flex flex-col gap-3"> + <section className="flex flex-col gap-2"> + <h3 className="text-sm font-medium">Renamed on the way in</h3> <p className="text-muted-foreground text-sm"> - The wallet accepted these older spellings and renamed them before reading further. A - document needing this is from an earlier generation of the format — it still works, and - nothing about it says which generation it is. + The wallet accepted these older spellings and read them under the current name. A document + needing this is from an earlier generation of the format — it still works, and nothing about + it says which generation it is. </p> <div className="overflow-x-auto"> <table className="w-full text-sm"> @@ -29,6 +38,6 @@ export function RewriteList({ rewrites }: { rewrites: NormalisationNote[] }) { </tbody> </table> </div> - </div> + </section> ); } diff --git a/apps/web/src/app/manifest/components/Verdict.test.tsx b/apps/web/src/app/manifest/components/Verdict.test.tsx new file mode 100644 index 0000000..4ceb2e4 --- /dev/null +++ b/apps/web/src/app/manifest/components/Verdict.test.tsx @@ -0,0 +1,230 @@ +import { describe, expect, test } from "bun:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { Verdict } from "./Verdict"; + +// AC-01, AC-02, AC-03 and AC-06's first half, at the only place they can be checked: the text +// a reader actually meets. Rendered to a string rather than to a DOM, because this repository +// has no DOM in its tests and react-dom is already here — the assertions below are about words +// on a screen, and a string carries those. + +function render(inspection: Parameters<typeof Verdict>[0]["inspection"]): string { + return renderToStaticMarkup(<Verdict inspection={inspection} />); +} + +const NOTHING_ASKED: Pick< + Parameters<typeof Verdict>[0]["inspection"], + "constructs" | "partial" | "rewrites" | "skipped" | "unreachable" +> = { + constructs: [], + partial: [], + rewrites: [], + skipped: [], + unreachable: ["covenant-mismatch", "shortfall", "no-fee-rate"], +}; + +describe("the answer this page came to give", () => { + test("says what the wallet would do before it says anything else", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { reason: 'This protocol is for "bitcoin".', reject: "foreign-chain" }, + }); + + expect(html.indexOf("would refuse to build an action")).toBeLessThan( + html.indexOf("Not decidable from a document at all"), + ); + }); + + test("leads with the reader's own sentence, which names where in the document", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { + reason: 'This protocol uses "args" at action Pay, which this wallet does not implement.', + reject: "unimplemented-construct", + }, + }); + + expect(html).toContain("action Pay"); + expect(html.indexOf("action Pay")).toBeLessThan(html.indexOf("unimplemented-construct")); + }); + + // The single most misreadable thing on the page. A document can be flawless in every way a + // document can be judged and still be unbuildable for want of money. + test("never lets no-refusal read as a promise that the wallet would build", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("Nothing a document alone can decide refuses this one"); + expect(html).toContain("not a statement that the wallet would build"); + }); +}); + +describe("what was never asked, beside the answer", () => { + test("names the unreachable checks whether or not a refusal was found", () => { + for (const refusal of [undefined, { reason: "…", reject: "foreign-chain" as const }]) { + const html = render({ ...NOTHING_ASKED, refusal }); + + expect(html).toContain("covenant-mismatch"); + expect(html).toContain("shortfall"); + expect(html).toContain("no-fee-rate"); + expect(html).toContain("Not decidable from a document at all"); + } + }); + + test("says why the unreachable ones are unreachable, and how many", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("3 of this wallet's refusals"); + expect(html).toContain("money"); + expect(html).toContain("chain read"); + }); + + // AC-02. Nothing that says a check was not made may hide behind a click: the absence of a + // refusal is only honest beside the list of what was never asked. + test("puts nothing unchecked inside a disclosure", () => { + const html = render({ + constructs: [], + partial: [{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }], + refusal: undefined, + rewrites: [], + skipped: ["foreign-asset"], + unreachable: ["shortfall"], + }); + + expect(html).not.toContain("<details"); + expect(html).not.toContain("hidden"); + }); + + test("separates checks it could have made from checks nothing could", () => { + const html = render({ + constructs: [], + partial: [], + refusal: undefined, + rewrites: [], + skipped: ["foreign-compiler"], + unreachable: ["shortfall"], + }); + + expect(html).toContain("Not checked, because this page has not been given what they need"); + expect(html).toContain("foreign-compiler"); + expect(html).toContain("Not decidable from a document at all"); + }); + + // Between skipped and done there is a third answer, and the page has to carry it or a check + // that read one of its two places is read as one that passed. + test("keeps a half-answered check apart from both a skipped one and a passed one", () => { + const html = render({ + ...NOTHING_ASKED, + partial: [{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }], + refusal: undefined, + }); + + expect(html).toContain("Checked in one of the two places that decide it"); + expect(html).toContain("./p2pk.simf"); + expect(html).not.toContain("Not checked, because"); + }); + + test("says which sources went unread rather than that some did", () => { + const html = render({ + ...NOTHING_ASKED, + partial: [{ reject: "foreign-compiler", unread: ["./lending.simf", "./script_auth.simf"] }], + refusal: undefined, + }); + + expect(html).toContain("./lending.simf"); + expect(html).toContain("./script_auth.simf"); + }); + + test("says nothing about a half-answered check when every check was answered in full", () => { + expect(render({ ...NOTHING_ASKED, refusal: undefined })).not.toContain("Checked in one of"); + }); + + test("says nothing about skipped checks when none were skipped", () => { + expect(render({ ...NOTHING_ASKED, refusal: undefined })).not.toContain("Not checked, because"); + }); + + test("tells a reader who can still answer that they can", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined, skipped: ["foreign-asset"] }); + + expect(html).toContain("Choose one above and they run"); + }); +}); + +describe("the runtime's own names for its refusals", () => { + // AC-03. A person cannot act on a reject token; they can act on the sentence beside it. The + // token stays for whoever is chasing one into the code, and stops being what they meet first. + test("never puts a token where the heading goes", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { reason: "This protocol is for bitcoin.", reject: "foreign-chain" }, + }); + + const headings = [...html.matchAll(/<h3[^>]*>([^<]*)<\/h3>/g)].map((match) => match[1]); + + expect(headings.length).toBeGreaterThan(0); + + for (const heading of headings) { + for (const token of ["foreign-chain", ...NOTHING_ASKED.unreachable]) { + expect(heading).not.toContain(token); + } + } + + expect(html.indexOf("This protocol is for bitcoin.")).toBeLessThan( + html.indexOf("foreign-chain"), + ); + }); +}); + +describe("older spellings, said once", () => { + // AC-06's first half. A renaming that succeeded changed nothing about the answer, so what is + // worth saying is that the document belongs to an earlier generation — one sentence, here. + test("counts them and says they changed nothing about the answer", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: undefined, + rewrites: [ + { at: "manifest", canonical: "manifest_version", found: "compose_version" }, + { at: "manifest", canonical: "params", found: "compile_params" }, + ], + }); + + expect(html).toContain("2 older spellings"); + expect(html).toContain("changed nothing about the answer"); + }); + + test("says so when a document needed none, rather than leaving it unsaid", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("current spelling"); + }); +}); + +describe("more than one field would refuse", () => { + // Found by using this page on the five published protocols: each refused on one decorative + // field and read as hopeless, when the field table below said three fixable gaps. + test("says how many fields would refuse, not only which one the wallet names", () => { + const html = render({ + ...NOTHING_ASKED, + constructs: [ + { at: "manifest", key: "$schema", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "contract_templates", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "simplicity_hl", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "description", site: "manifest", state: "shown" }, + ], + refusal: { reason: "…", reject: "unrecognised-construct" }, + }); + + expect(html).toContain("3 fields in this document would refuse"); + expect(html).toContain("The other 2"); + }); + + test("does not count when the wallet's one refusal is the whole of it", () => { + const html = render({ + ...NOTHING_ASKED, + constructs: [{ at: "manifest", key: "$schema", site: "manifest", state: "unrecognised" }], + refusal: { reason: "…", reject: "unrecognised-construct" }, + }); + + expect(html).not.toContain("would refuse, and the wallet names"); + }); +}); diff --git a/apps/web/src/app/manifest/components/Verdict.tsx b/apps/web/src/app/manifest/components/Verdict.tsx new file mode 100644 index 0000000..51e540f --- /dev/null +++ b/apps/web/src/app/manifest/components/Verdict.tsx @@ -0,0 +1,194 @@ +import type { ManifestInspection, RejectToken } from "@humid/tx-manifest"; + +/** + * What this wallet would do with the document, and — always beside it — what was never asked. + * + * The answer leads. Everything the reader computed is available further down the page, but a + * person holding a document is deciding one thing, and a page that opens with an inventory + * makes them assemble the answer themselves out of parts that all look equally important. + * + * The absence of a refusal is the most misreadable thing here. A document can be flawless in + * every way a document can be judged and still be unbuildable for want of money, a fee rate, + * or the covenant actually being where the state file says. So the unreached checks are not a + * footnote and never collapse: they are rendered in this same region, whether or not a refusal + * was found, and a tab or a disclosure would put back exactly the misreading they prevent. + * + * The runtime's own names for its refusals stay reachable and stop being headlines. A person + * cannot act on `unbuildable-utxo-type`; they can act on the sentence beside it, which names + * the position in the document. Eleven of those names set as badges was the page shouting its + * vocabulary at someone who came to ask a question. + * + * The second most misreadable thing was found by using this page on real documents. The + * runtime returns one refusal and does so deliberately: a person deciding whether to trust a + * site is not helped by a list of eleven field names. But a developer diagnosing coverage is + * misled by it — five published protocols each refused on one decorative field, and each read + * as hopeless when the truth was three fixable gaps. Saying how many fields are in that class + * is not disagreeing with the runtime's choice; it is this page declining to let one stand in + * for all of them. + */ +export function Verdict({ + inspection, +}: { + inspection: Pick< + ManifestInspection, + "constructs" | "partial" | "refusal" | "rewrites" | "skipped" | "unreachable" + >; +}) { + const wouldRefuse = inspection.constructs.filter( + (report) => report.state === "unimplemented" || report.state === "unrecognised", + ); + + return ( + <div className="flex flex-col gap-5"> + {(() => { + if (!inspection.refusal) { + return ( + <div className="flex flex-col gap-1"> + <p className="text-sm font-medium"> + Nothing a document alone can decide refuses this one. + </p> + <p className="text-muted-foreground text-sm"> + This is not a statement that the wallet would build an action from it. Read it with + what was not checked, below. + </p> + </div> + ); + } + + return ( + <div className="flex flex-col gap-1"> + <p className="text-sm font-medium"> + This wallet would refuse to build an action from this document. + </p> + <p className="text-sm">{inspection.refusal.reason}</p> + {wouldRefuse.length > 1 && ( + <p className="text-muted-foreground text-sm"> + {wouldRefuse.length} fields in this document would refuse, and the wallet names the + first. The other {wouldRefuse.length - 1} are in the field table below, under + unrecognised and unimplemented — fixing this one uncovers them rather than + finishing. + </p> + )} + <Names tokens={[inspection.refusal.reject]} /> + </div> + ); + })()} + + <p className="text-muted-foreground text-sm"> + {spellingSentence(inspection.rewrites.length)} + </p> + + {inspection.skipped.length > 0 && ( + <Unasked + heading="Not checked, because this page has not been given what they need" + explanations={whyUnasked(inspection.skipped)} + tokens={inspection.skipped} + /> + )} + + {inspection.partial.length > 0 && ( + <section className="flex flex-col gap-1"> + <h3 className="text-sm font-medium">Checked in one of the two places that decide it</h3> + <p className="text-muted-foreground text-sm"> + A compiler version is declared twice: by the document, and by a directive inside each + contract source. The document's own declaration was checked. These sources were not + read, so what they ask for is unknown — which is not the same as agreeing. Open them + above and the check completes. + </p> + {inspection.partial.map((check) => ( + <p key={check.reject} className="text-muted-foreground font-mono text-xs"> + {check.reject} · {check.unread.join(" · ")} + </p> + ))} + </section> + )} + + <Unasked + heading="Not decidable from a document at all" + explanations={[ + `${inspection.unreachable.length} of this wallet's refusals are decided against money, a chain read, a fee rate or a filled request. Reading a document establishes nothing about any of them, and no page holding none of those can.`, + ]} + tokens={inspection.unreachable} + /> + </div> + ); +} + +/** + * What the older spellings amount to, said once and in the verdict's own region. + * + * A renaming that succeeded changed nothing about the answer above it, which is precisely why + * a panel of its own was unreadable: it reported, at the weight of a finding, that nothing had + * happened. What is worth knowing is that the document belongs to an earlier generation of the + * format, and that is one sentence. A document needing none says so, because an absent + * sentence and a document nobody checked look the same. + */ +function spellingSentence(count: number): string { + if (count === 0) { + return "This document is written in the format's current spelling, so nothing was renamed on the way in."; + } + + return ( + `${count} older spellings were accepted and renamed on the way in. They changed nothing about ` + + "the answer above; the renamings themselves are listed with the fields below." + ); +} + +/** + * Why each unrun check was not run, in the reader's own terms. + * + * One sentence per missing input rather than per check, because two of them are missing the + * same thing and a person reading this is deciding what to do about it. Where the answer is + * theirs to give, the sentence says so — an explanation that only states what is absent + * leaves the page looking broken rather than waiting. + */ +function whyUnasked(skipped: readonly RejectToken[]): string[] { + const explanations: string[] = []; + + if (skipped.includes("foreign-compiler")) { + explanations.push( + "The compiler check needs the single SimplicityHL version a wallet ships, and the reader was given none.", + ); + } + + if (skipped.includes("foreign-asset") || skipped.includes("unbuildable-utxo-type")) { + explanations.push( + "The asset checks need the asset the network charges in, and no network is chosen. Choose one above and they run.", + ); + } + + return explanations; +} + +function Unasked({ + explanations, + heading, + tokens, +}: { + explanations: readonly string[]; + heading: string; + tokens: readonly string[]; +}) { + return ( + <section className="flex flex-col gap-1"> + <h3 className="text-sm font-medium">{heading}</h3> + {explanations.map((explanation) => ( + <p key={explanation} className="text-muted-foreground text-sm"> + {explanation} + </p> + ))} + <Names tokens={tokens} /> + </section> + ); +} + +/** + * The runtime's own names for the checks just described. + * + * Present because a developer chasing one of these into the code needs the exact string, and + * subordinate because nobody decides anything from it. Never a heading, never a badge, and + * never collapsed — the sentence above is what is being said, and this is the address of it. + */ +function Names({ tokens }: { tokens: readonly string[] }) { + return <p className="text-muted-foreground font-mono text-xs">{tokens.join(" · ")}</p>; +} diff --git a/apps/web/src/app/manifest/components/groupByState.test.ts b/apps/web/src/app/manifest/components/groupByState.test.ts index 06958c5..865e0e0 100644 --- a/apps/web/src/app/manifest/components/groupByState.test.ts +++ b/apps/web/src/app/manifest/components/groupByState.test.ts @@ -1,20 +1,43 @@ import { describe, expect, test } from "bun:test"; -import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; +import { + type ConstructReport, + type ConstructSiteKind, + type ConstructState, + inspectManifestDocument, +} from "@humid/tx-manifest"; +import dexManifest from "@humid/tx-manifest/fixtures/current/dex.manifest.json"; +import lendingV3Manifest from "@humid/tx-manifest/fixtures/current/lending_v3.manifest.json"; +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; import { groupByState } from "./groupByState"; -// AC-03's ordering half. What each field is comes from the package and is tested there; the -// order a person meets them in is this surface's own decision, and the reason it is worth -// asserting is that getting it wrong is invisible — a table still renders, with the field -// that would stop the build buried under thirty that would not. +// AC-04 and AC-05. What each field is comes from the package and is tested there; the order a +// person meets them in and how many rows that is are this surface's own decisions, and both +// are invisible when wrong — a table still renders, with the nine fields worth reading buried +// under six hundred that are working. -function report(state: ConstructState, key: string = state): ConstructReport { - return { at: "manifest", key, state }; +function report( + state: ConstructState, + key: string = state, + at = "manifest", + site: ConstructSiteKind = "manifest", +): ConstructReport { + return { at, key, site, state }; +} + +function rowsFor(document: unknown): number { + const inspection = inspectManifestDocument(document); + + if (!inspection.ok) { + throw new Error("expected a readable document"); + } + + return groupByState(inspection.constructs).reduce((total, group) => total + group.rows.length, 0); } describe("the order fields are shown in", () => { - test("leads with what no specification describes, and trails with what nothing reads", () => { + test("leads with what no specification describes, and trails with what is working", () => { const grouped = groupByState([ report("never-read"), report("shown"), @@ -26,9 +49,24 @@ describe("the order fields are shown in", () => { expect(grouped.map((group) => group.state)).toEqual([ "unrecognised", "unimplemented", + "never-read", + "shown", "acted-on", + ]); + }); + + test("collapses only the states that mean nothing is wrong", () => { + const grouped = groupByState([ + report("unrecognised"), + report("unimplemented"), + report("never-read"), + report("shown"), + report("acted-on"), + ]); + + expect(grouped.filter((group) => group.nothingWrong).map((group) => group.state)).toEqual([ "shown", - "never-read", + "acted-on", ]); }); @@ -39,24 +77,60 @@ describe("the order fields are shown in", () => { expect(grouped[0]?.state).toBe("acted-on"); }); - test("keeps every field, so nothing is grouped away", () => { - const constructs = [ + test("a document declaring nothing groups into nothing", () => { + expect(groupByState([])).toEqual([]); + }); +}); + +describe("one row per construct, not per position", () => { + test("gathers every position a key was found at into its one row", () => { + const grouped = groupByState([ + report("acted-on", "amount_sat", "action Pay / output p2pk_out", "output"), + report("acted-on", "amount_sat", "action Refund / output refund_out", "output"), + ]); + + expect(grouped[0]?.rows).toHaveLength(1); + expect(grouped[0]?.rows[0]?.at).toEqual([ + "action Pay / output p2pk_out", + "action Refund / output refund_out", + ]); + }); + + // The same key at two kinds of position is two constructs and can be in two states. Merging + // them by name alone would print one row whose state is whichever the loop met last. + test("keeps the same key apart when it sits at different kinds of position", () => { + const grouped = groupByState([ + report("shown", "description", "action Pay", "action"), + report("shown", "description", "action Pay / output p2pk_out", "output"), + ]); + + expect(grouped[0]?.rows).toHaveLength(2); + }); + + test("loses no position, so the whole document is still reachable", () => { + const positions = groupByState([ report("acted-on", "chain"), report("acted-on", "utxo_types"), report("shown", "description"), - ]; + ]).flatMap((group) => group.rows.flatMap((row) => row.at)); + + expect(positions).toHaveLength(3); + }); +}); - const kept = groupByState(constructs).flatMap((group) => group.entries); +// AC-04's own numbers, taken from the published protocols rather than from a document written +// to make the assertion pass. The second figure in each name is what the table drew before +// this change: one row per position. +describe("what the published protocols now draw", () => { + test("the deployed lending protocol: 57 rows rather than 620", () => { + expect(rowsFor(lendingV3Manifest)).toBe(57); + }); - expect(kept).toHaveLength(3); - expect(kept.map((entry) => entry.key).toSorted()).toEqual([ - "chain", - "description", - "utxo_types", - ]); + test("the exchange protocol: 50 rows rather than 235", () => { + expect(rowsFor(dexManifest)).toBe(50); }); - test("a document declaring nothing groups into nothing", () => { - expect(groupByState([])).toEqual([]); + test("the simplest published protocol: 40 rows rather than 69", () => { + expect(rowsFor(p2pkManifest)).toBe(40); }); }); diff --git a/apps/web/src/app/manifest/components/groupByState.ts b/apps/web/src/app/manifest/components/groupByState.ts index bc187dd..fc9f23f 100644 --- a/apps/web/src/app/manifest/components/groupByState.ts +++ b/apps/web/src/app/manifest/components/groupByState.ts @@ -1,27 +1,58 @@ -import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; +import type { ConstructReport, ConstructSiteKind, ConstructState } from "@humid/tx-manifest"; /** * The order a reader wants: what stops the build first, then what merely is. * * `unrecognised` leads because it is the one state that means nobody has ever specified this - * field here, and `never-read` trails because knowing a field is deliberately ignored is the - * least urgent thing this table says. + * field here. `acted-on` trails, and used to come third, because it is the state of a field + * that works — for the deployed lending protocol that is 360 of 620 reports, and putting them + * before the rest buried the nine worth reading. */ const ORDER: ConstructState[] = [ "unrecognised", "unimplemented", - "acted-on", - "shown", "never-read", + "shown", + "acted-on", ]; +/** + * The states that mean nothing is wrong, and are therefore collapsed until asked for. + * + * Not hidden and not dropped: a reader who wants the whole document is one click away and the + * count is visible without clicking. What is removed is the default of meeting 611 rows that + * each say "this field works" before reaching the nine that say anything else. + */ +const NOTHING_WRONG = new Set<ConstructState>(["shown", "acted-on"]); + +/** One construct, and every position in this document that declares it. */ +export type FieldRow = { + /** Where it was found, in the document's own terms, in the order the document lists them. */ + at: string[]; + key: string; + site: ConstructSiteKind; +}; + export type ConstructGroup = { - entries: ConstructReport[]; + /** Whether this state means nothing is wrong, and so opens collapsed. */ + nothingWrong: boolean; + rows: FieldRow[]; state: ConstructState; }; /** - * Groups one document's fields by what the wallet does with them, in reading order. + * Groups one document's fields by what the wallet does with them, in reading order, and + * collapses each construct into one row carrying every position it was found at. + * + * The table used to draw one row per position, which is one row per key per place that key + * appears: 620 rows for the deployed lending protocol, over 41 distinct keys and 94 places. + * Nothing there was wrong and nothing was readable, because the repetition is inherent to the + * shape of the data rather than to anything the document did. + * + * A construct is a key at a kind of position, which is how the runtime's own table is keyed: + * `description` on an action and `description` on an output are two constructs and can be in + * two different states. Aggregating by key alone would merge them into one row whose state is + * whichever the loop met last. * * A function rather than a few lines inside the component because it is the only decision * that surface makes: everything else there is layout. There is no DOM in this repository's @@ -29,7 +60,26 @@ export type ConstructGroup = { */ export function groupByState(constructs: ConstructReport[]): ConstructGroup[] { return ORDER.map((state) => ({ - entries: constructs.filter((report) => report.state === state), + nothingWrong: NOTHING_WRONG.has(state), + rows: rowsOf(constructs.filter((report) => report.state === state)), state, - })).filter((group) => group.entries.length > 0); + })).filter((group) => group.rows.length > 0); +} + +function rowsOf(reports: ConstructReport[]): FieldRow[] { + const rows = new Map<string, FieldRow>(); + + for (const report of reports) { + const identity = `${report.site}/${report.key}`; + const row = rows.get(identity); + + if (row) { + row.at.push(report.at); + continue; + } + + rows.set(identity, { at: [report.at], key: report.key, site: report.site }); + } + + return [...rows.values()].toSorted((left, right) => left.key.localeCompare(right.key)); } diff --git a/apps/web/src/app/manifest/index.test.tsx b/apps/web/src/app/manifest/index.test.tsx index c41439e..cb749ed 100644 --- a/apps/web/src/app/manifest/index.test.tsx +++ b/apps/web/src/app/manifest/index.test.tsx @@ -27,10 +27,15 @@ describe("the inspector with nothing around it", () => { test("shows no result panels until something is pasted", () => { const html = renderToStaticMarkup(<ManifestInspector />); - expect(html).not.toContain("What the wallet would refuse"); + expect(html).not.toContain("What this wallet would do"); expect(html).not.toContain("What each field is"); - expect(html).not.toContain("What was rewritten"); - expect(html).not.toContain("The contracts this document references"); + }); + + // The file picker asks for an input, not a result, and until a document says which contracts + // it references there is nothing to ask for. So it appears with the document rather than + // beside the answer, which is where it used to be. + test("asks for contract sources only once a document has named some", () => { + expect(renderToStaticMarkup(<ManifestInspector />)).not.toContain("Contract sources"); }); test("offers a document to start from, so the empty box is not the only way in", () => { diff --git a/apps/web/src/app/manifest/index.tsx b/apps/web/src/app/manifest/index.tsx index 8f6051c..8590c31 100644 --- a/apps/web/src/app/manifest/index.tsx +++ b/apps/web/src/app/manifest/index.tsx @@ -16,8 +16,8 @@ import { LIQUID_NETWORKS, liquidNetworkByChainId } from "@/lib/liquid-networks"; import { ConstructTable } from "./components/ConstructTable"; import { ContractSourceList } from "./components/ContractSourceList"; -import { RefusalPanel } from "./components/RefusalPanel"; import { RewriteList } from "./components/RewriteList"; +import { Verdict } from "./components/Verdict"; import { matchContractSources, type SuppliedSource } from "./contractSources"; import { readDocument } from "./readDocument"; @@ -29,21 +29,29 @@ import { readDocument } from "./readDocument"; const NO_NETWORK = "none"; /** - * What this wallet makes of a txManifest document, without building anything from it. + * What this wallet would do with a txManifest document, without building anything from it. * - * The three panels are the three questions a protocol author cannot answer from outside: - * which older spellings still work, which fields this wallet acts on rather than tolerates, - * and what it would refuse before it touches money. All three come from `@humid/tx-manifest` - * — the same package the wallet itself reads a document with — so this page cannot describe - * a parser that differs from the one that runs. + * The page answers one question and answers it first: would this wallet refuse, and why. It + * used to open with an account of everything the reader computed — one region per field of + * the reader's return value, in the order that value declares them — which is a dump of a + * data structure rather than an answer, and left the person holding the document to work out + * which part of it bore on anything. + * + * So there is a verdict, and everything else is under it. What the reader was never able to + * check sits inside the verdict rather than below it, because the absence of a refusal is + * only honest beside the list of what was never asked; see {@link Verdict}. + * + * Everything shown comes from `@humid/tx-manifest` — the same package the wallet itself reads + * a document with — so this page cannot describe a parser that differs from the one that runs. * * It connects to nothing. There is no wallet here, no chain read and no request, which is - * both the point and the limit: see {@link RefusalPanel} for what that costs. + * both the point and the limit. * - * The network is asked for rather than read from anywhere, because a document names a chain - * family and the two Liquid networks charge in different assets. Unanswered is a real state - * and the one it opens in — the checks that need that asset are then reported as not run, - * which is not the same as passing them. + * The network and the contract sources are asked for in the input card rather than reported + * as results, because that is what they are: a document names a chain family and the two + * Liquid networks charge in different assets, and a contract source declares a compiler + * version the document also declares. Unanswered is a real state and the one this opens in — + * the checks needing those inputs are reported as not run, which is not the same as passing. */ export default function ManifestInspector() { const [text, setText] = useState(""); @@ -57,14 +65,14 @@ export default function ManifestInspector() { // and the second is the one the page reports. const { document, matched } = useMemo(() => { const referenced = readDocument(text, { network }); - const matched = matchContractSources( + const byReferencedPath = matchContractSources( referenced.kind === "read" && referenced.ok ? referenced.contracts : [], suppliedSources, ); return { - document: readDocument(text, { contractSources: matched.sources, network }), - matched, + document: readDocument(text, { contractSources: byReferencedPath.sources, network }), + matched: byReferencedPath, }; }, [text, network, suppliedSources]); @@ -120,6 +128,15 @@ export default function ManifestInspector() { Clear </Button> </div> + {document.kind === "read" && document.ok && ( + <ContractSourceList + contracts={document.contracts} + onClear={() => setSuppliedSources([])} + onSupply={setSuppliedSources} + supplied={matched.sources} + unmatched={matched.unmatched} + /> + )} </CardContent> </Card> @@ -147,34 +164,19 @@ export default function ManifestInspector() { return ( <> <Panel - title="What the wallet would refuse" - description="Checked before anything is built, and only against the document itself." - > - <RefusalPanel inspection={document} /> - </Panel> - <Panel - title="The contracts this document references" - description="Read here in the page, so the compiler check reads both places that declare a version rather than one." + title="What this wallet would do" + description="Decided from the document alone, before anything is built." > - <ContractSourceList - contracts={document.contracts} - onClear={() => setSuppliedSources([])} - onSupply={setSuppliedSources} - supplied={matched.sources} - unmatched={matched.unmatched} - /> + <Verdict inspection={document} /> </Panel> <Panel title="What each field is" description="Every field this document declares, against the position it sits in." > - <ConstructTable constructs={document.constructs} /> - </Panel> - <Panel - title="What was rewritten" - description="Spellings from earlier generations of the format, renamed on the way in." - > - <RewriteList rewrites={document.rewrites} /> + <div className="flex flex-col gap-6"> + <ConstructTable constructs={document.constructs} /> + <RewriteList rewrites={document.rewrites} /> + </div> </Panel> </> ); diff --git a/apps/web/src/app/manifest/readDocument.test.ts b/apps/web/src/app/manifest/readDocument.test.ts index 23d2940..7da93e5 100644 --- a/apps/web/src/app/manifest/readDocument.test.ts +++ b/apps/web/src/app/manifest/readDocument.test.ts @@ -79,19 +79,20 @@ describe("what the textarea currently holds", () => { // on, so what matters is not that a value arrives but that the same documents come back refused // here as there — and that no network still means no answer rather than a passing one. -describe("the two checks that need the network's own asset", () => { - function read(manifest: unknown, network?: typeof LIQUID_MAINNET) { - const result = readDocument(JSON.stringify(manifest), { network }); +/** Reads a document with a network supplied, or deliberately without one. */ +function readWithNetwork(manifest: unknown, network?: typeof LIQUID_MAINNET) { + const result = readDocument(JSON.stringify(manifest), { network }); - if (result.kind !== "read" || !result.ok) { - throw new Error("expected a readable document"); - } - - return result; + if (result.kind !== "read" || !result.ok) { + throw new Error("expected a readable document"); } + return result; +} + +describe("the two checks that need the network's own asset", () => { test("with no network chosen, both stay reported as not run", () => { - const result = read(dexManifest); + const result = readWithNetwork(dexManifest); expect(result.skipped).toContain("foreign-asset"); expect(result.skipped).toContain("unbuildable-utxo-type"); @@ -99,7 +100,7 @@ describe("the two checks that need the network's own asset", () => { }); test("with a network chosen, neither is reported as not run any more", () => { - const result = read(zeroconfManifest, LIQUID_MAINNET); + const result = readWithNetwork(zeroconfManifest, LIQUID_MAINNET); // Nothing is left unrun: this document declares no covenant contracts, so the compiler // check has only the document's own declaration to read and has read it. @@ -107,33 +108,86 @@ describe("the two checks that need the network's own asset", () => { expect(result.partial).toEqual([]); }); - // The three the wallet refuses on the asset they move. Named rather than counted: a document - // dropping out of this list is the page and the wallet disagreeing again, which is the whole + // The three the wallet used to refuse on the asset they move, and no longer does. Each states + // its assets as lookups into a deployment the document does not carry, so reading the + // document cannot say which asset they are. Named rather than counted: a document dropping + // back into a refusal here is the page and the wallet disagreeing again, which is the whole // failure this page exists to prevent. test.each([ ["dex", dexManifest], ["lending_v2", lendingV2Manifest], ["lending_v3", lendingV3Manifest], - ])("%s is refused on the asset it moves", (_name, manifest) => { - expect(read(manifest, LIQUID_MAINNET).refusal?.reject).toBe("foreign-asset"); + ])("%s is no longer refused on the asset it moves", (_name, manifest) => { + expect(readWithNetwork(manifest, LIQUID_MAINNET).refusal).toBeUndefined(); + }); + + // The refusal moved rather than went, and the page has to keep reaching it. A document that + // commits to an asset outright still earns it from the document alone. + test("a document that names an asset outright is still refused on it", () => { + const refusal = readWithNetwork( + { + actions: { + Move: { + outputs: [ + { + amount_sat: 1000, + asset: "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d", + destination: "wallet", + id: "out_one", + }, + ], + }, + }, + chain: "liquid", + compose_version: "1.0", + }, + LIQUID_MAINNET, + ).refusal; + + expect(refusal?.reject).toBe("foreign-asset"); }); test.each([ ["last_will", lastWillManifest], ["zeroconf", zeroconfManifest], ])("%s is not refused, and is not claimed to be unchecked either", (_name, manifest) => { - const result = read(manifest, LIQUID_MAINNET); + const result = readWithNetwork(manifest, LIQUID_MAINNET); expect(result.refusal).toBeUndefined(); expect(result.skipped).not.toContain("foreign-asset"); }); - // Both networks carry a different asset and the answer happens to be the same, because these - // documents name assets no Liquid network charges in. Asserted so that stops being invisible - // if it ever changes. + // Both networks carry a different asset and the answer is the same across this corpus, + // because no document in it names an asset at all. Asserted so that stops being invisible if + // it ever changes. test("testnet reaches the same verdict as mainnet on the published corpus", () => { - expect(read(dexManifest, LIQUID_TESTNET).refusal?.reject).toBe("foreign-asset"); - expect(read(lastWillManifest, LIQUID_TESTNET).refusal).toBeUndefined(); + expect(readWithNetwork(dexManifest, LIQUID_TESTNET).refusal).toBeUndefined(); + expect(readWithNetwork(lastWillManifest, LIQUID_TESTNET).refusal).toBeUndefined(); + }); + + // And which network is chosen still decides the answer, which is the reason this check needs + // one at all. The same document is built on the network that charges in that asset and + // refused on the one that does not. + test("but a document naming one network's asset is refused on the other", () => { + const document = { + actions: { + Move: { + outputs: [ + { + amount_sat: 1000, + asset: LIQUID_TESTNET.policyAsset, + destination: "wallet", + id: "out_one", + }, + ], + }, + }, + chain: "liquid", + compose_version: "1.0", + }; + + expect(readWithNetwork(document, LIQUID_TESTNET).refusal).toBeUndefined(); + expect(readWithNetwork(document, LIQUID_MAINNET).refusal?.reject).toBe("foreign-asset"); }); }); @@ -141,37 +195,38 @@ describe("the two checks that need the network's own asset", () => { // places a version is declared. The other is inside each contract source, and this is the page // saying which of those it read rather than letting the half it did read stand for both. -describe("the compiler check, which is declared in two places", () => { - function read(manifest: unknown, contractSources?: Record<string, string>) { - const result = readDocument(JSON.stringify(manifest), { contractSources }); +/** Reads a document with the contract sources a caller carries, or without them. */ +function readWithSources(manifest: unknown, contractSources?: Record<string, string>) { + const result = readDocument(JSON.stringify(manifest), { contractSources }); - if (result.kind !== "read" || !result.ok) { - throw new Error("expected a readable document"); - } - - return result; + if (result.kind !== "read" || !result.ok) { + throw new Error("expected a readable document"); } + return result; +} + +describe("the compiler check, which is declared in two places", () => { test("names the contract sources the document references, supplied or not", () => { - expect(read(lastWillManifest).contracts).toEqual(["./last_will.simf"]); + expect(readWithSources(lastWillManifest).contracts).toEqual(["./last_will.simf"]); }); test("with no sources, says the contracts went unread rather than reporting the check run", () => { - const result = read(lastWillManifest); + const result = readWithSources(lastWillManifest); expect(result.skipped).not.toContain("foreign-compiler"); expect(result.partial).toEqual([{ reject: "foreign-compiler", unread: ["./last_will.simf"] }]); }); test("with every referenced source supplied, the check is answered in full", () => { - const result = read(lastWillManifest, { "./last_will.simf": "fn main() {}" }); + const result = readWithSources(lastWillManifest, { "./last_will.simf": "fn main() {}" }); expect(result.partial).toEqual([]); expect(result.refusal).toBeUndefined(); }); test("refuses a source asking for a version this wallet does not ship, naming the file", () => { - const result = read(lastWillManifest, { + const result = readWithSources(lastWillManifest, { "./last_will.simf": 'simc "9.9.9"\nfn main() {}', }); @@ -182,7 +237,7 @@ describe("the compiler check, which is declared in two places", () => { }); test("a source that asks for the version this wallet ships is not refused", () => { - const result = read(lastWillManifest, { + const result = readWithSources(lastWillManifest, { "./last_will.simf": `simc "${SMPLX_COMPILER_VERSION}"\nfn main() {}`, }); diff --git a/apps/web/src/contexts/Web3Provider/connect.test.ts b/apps/web/src/contexts/Web3Provider/connect.test.ts new file mode 100644 index 0000000..88873df --- /dev/null +++ b/apps/web/src/contexts/Web3Provider/connect.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test"; + +import { connectOnce, type ConnectHost, type ConnectSignal } from "./connect"; + +/** + * A stand-in for AppKit's events, built to the same shape as the real one. + * + * The detail this file exists for is that the real controller retains its last event forever: + * the value stays in state after the attempt that produced it is over, and anything reading + * that value rather than subscribing sees the previous attempt's ending as if it had just + * happened. `last` here is that retained value. `subscribe` never replays it — which is what + * the real subscription does, and what the connect attempt is required to rely on. + */ +function fakeAppKit() { + const listeners = new Set<(signal: ConnectSignal) => void>(); + const opened: number[] = []; + const closed: number[] = []; + let last: ConnectSignal | undefined; + let openFails: Error | undefined; + + const host: ConnectHost = { + close: async () => { + closed.push(Date.now()); + }, + open: async () => { + opened.push(Date.now()); + + if (openFails) { + throw openFails; + } + }, + subscribeEvents: (listener) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; + + return { + closes: () => closed.length, + emit: (signal: ConnectSignal) => { + last = signal; + + // Deleting the current entry mid-iteration is defined behaviour on a Set, which is + // what a listener that unsubscribes itself on the event it just received does. + for (const listener of listeners) { + listener(signal); + } + }, + failOpenWith: (error: Error) => { + openFails = error; + }, + host, + listeners: () => listeners.size, + opens: () => opened.length, + retained: () => last, + }; +} + +/** What an attempt has come to, without awaiting it and without leaving a rejection unhandled. */ +function watch(attempt: Promise<void>) { + const state = { outcome: "pending" as "pending" | "rejected" | "resolved", reason: "" }; + + attempt.then( + () => { + state.outcome = "resolved"; + }, + (error: unknown) => { + state.outcome = "rejected"; + state.reason = error instanceof Error ? error.message : String(error); + }, + ); + + return state; +} + +/** One turn of the event loop, which is longer than anything here needs to settle. */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("what settles one connect attempt", () => { + // The defect this file was written for. A person closes the wallet window, is told so, and + // clicks connect again — and the second attempt was refused before the window opened, + // because the ending of the first one was still standing in AppKit's event state. + test("an event from before the attempt began does not settle it", async () => { + const appKit = fakeAppKit(); + + appKit.emit({ connected: false, name: "MODAL_CLOSE" }); + expect(appKit.retained()).toEqual({ connected: false, name: "MODAL_CLOSE" }); + + const attempt = watch(connectOnce(appKit.host)); + + await settle(); + + expect(attempt.outcome).toBe("pending"); + expect(appKit.opens()).toBe(1); + + appKit.emit({ name: "CONNECT_SUCCESS" }); + await settle(); + + expect(attempt.outcome).toBe("resolved"); + }); + + // The same thing said as the person meets it: connecting has to work the second time. + test("connecting again after the window was closed reaches the wallet", async () => { + const appKit = fakeAppKit(); + + const first = watch(connectOnce(appKit.host)); + + appKit.emit({ connected: false, name: "MODAL_CLOSE" }); + await settle(); + + expect(first.outcome).toBe("rejected"); + expect(first.reason).toBe("User closed the modal"); + + const second = watch(connectOnce(appKit.host)); + + await settle(); + + expect(second.outcome).toBe("pending"); + expect(appKit.opens()).toBe(2); + + appKit.emit({ name: "CONNECT_SUCCESS" }); + await settle(); + + expect(second.outcome).toBe("resolved"); + }); + + test("a connected wallet resolves the attempt and closes the window", async () => { + const appKit = fakeAppKit(); + const attempt = watch(connectOnce(appKit.host)); + + appKit.emit({ name: "CONNECT_SUCCESS" }); + await settle(); + + expect(attempt.outcome).toBe("resolved"); + expect(appKit.closes()).toBe(1); + }); + + test("a wallet that reports a failure rejects the attempt and closes the window", async () => { + const appKit = fakeAppKit(); + const attempt = watch(connectOnce(appKit.host)); + + appKit.emit({ name: "CONNECT_ERROR" }); + await settle(); + + expect(attempt.outcome).toBe("rejected"); + expect(attempt.reason).toBe("Failed to connect to the wallet"); + expect(appKit.closes()).toBe(1); + }); + + // Closing means two opposite things, and the flag is the only thing that separates them. + test("a window closed after connecting resolves rather than refuses", async () => { + const appKit = fakeAppKit(); + const attempt = watch(connectOnce(appKit.host)); + + appKit.emit({ connected: true, name: "MODAL_CLOSE" }); + await settle(); + + expect(attempt.outcome).toBe("resolved"); + }); + + // The person already closed it. Closing it again would be a second ending reported about an + // attempt that has none left. + test("a window the person closed is not closed a second time", async () => { + const appKit = fakeAppKit(); + const attempt = watch(connectOnce(appKit.host)); + + appKit.emit({ connected: false, name: "MODAL_CLOSE" }); + await settle(); + + expect(attempt.outcome).toBe("rejected"); + expect(appKit.closes()).toBe(0); + }); + + // What keeps the guarantee true for the next attempt rather than only for this one. + test("a settled attempt is listening to nothing and cannot be settled again", async () => { + const appKit = fakeAppKit(); + const attempt = watch(connectOnce(appKit.host)); + + expect(appKit.listeners()).toBe(1); + + appKit.emit({ connected: false, name: "MODAL_CLOSE" }); + await settle(); + + expect(attempt.outcome).toBe("rejected"); + expect(appKit.listeners()).toBe(0); + + appKit.emit({ name: "CONNECT_SUCCESS" }); + await settle(); + + expect(attempt.outcome).toBe("rejected"); + expect(appKit.closes()).toBe(0); + }); + + // Without this the promise waits forever on a window that never appeared. + test("a window that cannot be opened refuses the attempt", async () => { + const appKit = fakeAppKit(); + + appKit.failOpenWith(new Error("The modal did not open")); + + const attempt = watch(connectOnce(appKit.host)); + + await settle(); + + expect(attempt.outcome).toBe("rejected"); + expect(attempt.reason).toBe("The modal did not open"); + expect(appKit.listeners()).toBe(0); + }); + + test("an event about nothing this attempt is waiting for leaves it running", async () => { + const appKit = fakeAppKit(); + const attempt = watch(connectOnce(appKit.host)); + + appKit.emit({ connected: false, name: "MODAL_OPEN" }); + appKit.emit({ name: "SELECT_WALLET" }); + await settle(); + + expect(attempt.outcome).toBe("pending"); + + appKit.emit({ name: "CONNECT_SUCCESS" }); + await settle(); + + expect(attempt.outcome).toBe("resolved"); + }); +}); diff --git a/apps/web/src/contexts/Web3Provider/connect.ts b/apps/web/src/contexts/Web3Provider/connect.ts new file mode 100644 index 0000000..6c12a28 --- /dev/null +++ b/apps/web/src/contexts/Web3Provider/connect.ts @@ -0,0 +1,115 @@ +/** + * One connect attempt, settled by what happens after it starts and by nothing else. + * + * This exists because the obvious way to write it is wrong in a way nobody sees until the + * second attempt. AppKit publishes what happened as a single retained value: its events + * controller keeps the last event in state, and the React hook over it seeds itself from that + * standing value and then holds it indefinitely. Settling an attempt by reading that value + * means an attempt can be settled by an event from a previous one — so a person who closes the + * wallet window once is refused every time afterwards, before the window even opens, until + * they reload the page. + * + * The shape that removes the class of bug rather than the instance is to subscribe for the + * duration of the attempt and read nothing standing. A subscription only ever delivers what + * has just happened, so an attempt cannot be settled by anything that happened before it + * began, whatever the caller did previously. + */ + +/** + * What one attempt needs of the wallet modal, and deliberately nothing more. + * + * Named as three plain operations rather than as AppKit's own types so that the rule above is + * checkable without a browser, a modal or a wallet. `subscribeEvents` returns the function that + * ends the subscription, which is the whole of what makes an attempt bounded. + */ +export type ConnectHost = { + close: () => Promise<void>; + /** Whatever opening produced is discarded: an attempt turns on the events, not on this. */ + open: () => Promise<unknown>; + subscribeEvents: (listener: (signal: ConnectSignal) => void) => () => void; +}; + +/** + * One thing the modal reported, reduced to what decides an attempt. + * + * `connected` is stated only where the modal states it — closing the window means two opposite + * things depending on whether a wallet was connected first, and the flag is the only thing that + * separates them. + */ +export type ConnectSignal = { + connected?: boolean; + name: string; +}; + +/** + * Opens the wallet modal and resolves when this attempt connects. + * + * Rejects when the wallet reports a failure, when the person closes the window without + * connecting, and when the modal cannot be opened at all. Every one of those is terminal for + * this attempt and for no other: the subscription is dropped the moment it settles, so a later + * event belongs to whatever attempt is running then. + */ +export function connectOnce(host: ConnectHost): Promise<void> { + return new Promise<void>((resolve, reject) => { + let settled = false; + let unsubscribe: (() => void) | undefined; + + const settle = (outcome: () => void, closeModal: boolean) => { + if (settled) { + return; + } + + settled = true; + unsubscribe?.(); + + // The window is already gone when the person closed it themselves; closing it again + // would be a second close event about an attempt that has already ended. + if (closeModal) { + void host.close(); + } + + outcome(); + }; + + const rejectWith = (message: string) => () => { + reject(new Error(message)); + }; + + unsubscribe = host.subscribeEvents((signal) => { + if (signal.name === "CONNECT_SUCCESS") { + settle(resolve, true); + + return; + } + + if (signal.name === "CONNECT_ERROR") { + settle(rejectWith("Failed to connect to the wallet"), true); + + return; + } + + if (signal.name === "MODAL_CLOSE") { + if (signal.connected) { + settle(resolve, true); + } else { + settle(rejectWith("User closed the modal"), false); + } + } + }); + + // A host that answers synchronously has already settled by the time the unsubscribe + // function exists, and the line above could not have run it. Nothing in AppKit does + // this; a subscription left behind by an attempt that is over is worth one branch. + if (settled) { + unsubscribe(); + } + + // Opening is what the attempt is waiting on, so a failure to open is the attempt + // failing. Without this the promise would stay pending for a window that never appeared. + host.open().catch((error: unknown) => { + settle(() => { + reject(error instanceof Error ? error : new Error(String(error))); + }, false); + }); + }); +} diff --git a/apps/web/src/contexts/Web3Provider/index.tsx b/apps/web/src/contexts/Web3Provider/index.tsx index fd9e9a9..079b188 100644 --- a/apps/web/src/contexts/Web3Provider/index.tsx +++ b/apps/web/src/contexts/Web3Provider/index.tsx @@ -6,16 +6,20 @@ import { createContext, useContext, useEffect, - useRef, + useMemo, useState, type PropsWithChildren, } from "react"; +import { connectOnce } from "./connect"; import { HumidProvider } from "./HumidProvider"; // Injected HUMID Liquid wallet in one call: the preset adapter carries every default (connector, // window.humid, method set, ecdsa signMessage), and the networks come straight from the package. -createAppKit({ +// The instance is kept rather than discarded because a connect attempt subscribes to its events +// for as long as it runs, which is how it hears what happened during this attempt rather than +// what stands from the last one. +const appKit = createAppKit({ adapters: [new HumidAdapter()], networks: liquidNetworks, defaultNetwork: liquidTestnet, @@ -77,56 +81,30 @@ export const useWeb3Context = () => useContext(web3Context); * Promise-based wrapper over AppKit's imperative connect modal: opens the Connect view for a namespace * and resolves on CONNECT_SUCCESS, rejecting on CONNECT_ERROR or when the user closes the modal without * connecting. Lets callers `await connect()` instead of wiring up AppKit events themselves. + * + * Each attempt subscribes for its own lifetime. `useAppKitEvents` looks like the hook for this and is + * the wrong tool: it seeds itself from AppKit's retained last event and then holds it, so an attempt + * reading it is settled by whatever the previous attempt ended on. `connect.ts` states that rule and + * carries the tests for it. */ const useConnectAsync = () => { const { open, close } = useAppKit(); const { disconnect } = useDisconnect(); - const appKitEvent = useAppKitEvents(); - - const [isConnecting, setIsConnecting] = useState(false); - - const resolveRef = useRef<() => void>(() => {}); - const rejectRef = useRef<(error?: Error) => void>(() => {}); - - useEffect(() => { - if (!isConnecting) return; - - if (appKitEvent?.data.event === "CONNECT_SUCCESS") { - resolveRef.current(); - close(); - setIsConnecting(false); - return; - } - - if (appKitEvent?.data.event === "CONNECT_ERROR") { - rejectRef.current(new Error("Failed to connect to the wallet")); - close(); - setIsConnecting(false); - return; - } - - if (appKitEvent?.data.event === "MODAL_CLOSE") { - if (!appKitEvent.data.properties.connected) { - rejectRef.current(new Error("User closed the modal")); - setIsConnecting(false); - return; - } - - resolveRef.current(); - close(); - setIsConnecting(false); - } - }, [appKitEvent, close, isConnecting]); return async (namespace: string) => { await disconnect({ namespace: namespace as ChainNamespace }); - setIsConnecting(true); - - return new Promise<void>((resolve, reject) => { - open({ view: "Connect", namespace: namespace as ChainNamespace }); - resolveRef.current = resolve; - rejectRef.current = reject; + return connectOnce({ + close, + open: () => open({ namespace: namespace as ChainNamespace, view: "Connect" }), + subscribeEvents: (listener) => + appKit.subscribeEvents(({ data }) => + listener( + data.event === "MODAL_CLOSE" + ? { connected: data.properties.connected, name: data.event } + : { name: data.event }, + ), + ), }); }; }; @@ -147,10 +125,14 @@ export const Web3Provider = ({ children }: PropsWithChildren) => { } }, [appKitEvent]); + // Built once per change rather than per render: a fresh object here re-renders every consumer + // of this context on any render of the provider, and the two values change rarely. + const value = useMemo(() => ({ connectAsync, isInitialized }), [connectAsync, isInitialized]); + if (!isInitialized) return null; return ( - <web3Context.Provider value={{ isInitialized, connectAsync }}> + <web3Context.Provider value={value}> <QueryClientProvider client={queryClient}> <HumidProvider>{children}</HumidProvider> </QueryClientProvider> diff --git a/package.json b/package.json index f38985d..d5b705a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "type": "module", "scripts": { "analyze": "vite build --mode analyze", - "build": "tsc && vite build", + "build": "bun run check:wasm && tsc && vite build", "build-storybook": "storybook build", "build:wasm": "smplx/crates/wasm/build.sh", "build:watch": "vite build --watch --mode development", @@ -30,7 +30,8 @@ "web:build": "bun --filter='./apps/web' run build", "web:dev": "bun --filter='./apps/web' run dev", "web:preview": "bun --filter='./apps/web' run preview", - "web:typecheck": "bun --filter='./apps/web' run typecheck" + "web:typecheck": "bun --filter='./apps/web' run typecheck", + "check:wasm": "bun run scripts/checkSmplxWasm.ts" }, "dependencies": { "@base-ui/react": "^1.5.0", diff --git a/packages/tx-manifest/src/chain/bytes.ts b/packages/tx-manifest/src/chain/bytes.ts index 86e5ac5..2edc536 100644 --- a/packages/tx-manifest/src/chain/bytes.ts +++ b/packages/tx-manifest/src/chain/bytes.ts @@ -121,3 +121,16 @@ export function readVarint(reader: Reader): bigint | undefined { return value; } + +/** One byte, leaving the reader after it. */ +export function readUint8(reader: Reader): number | undefined { + if (reader.at + 1 > reader.bytes.length) { + return undefined; + } + + const byte = reader.bytes[reader.at]; + + reader.at += 1; + + return byte; +} diff --git a/packages/tx-manifest/src/chain/chainRead.test.ts b/packages/tx-manifest/src/chain/chainRead.test.ts index fab47fd..0c6482a 100644 --- a/packages/tx-manifest/src/chain/chainRead.test.ts +++ b/packages/tx-manifest/src/chain/chainRead.test.ts @@ -2,7 +2,11 @@ import { describe, expect, test } from "bun:test"; import transactions from "../__fixtures__/testnet-transactions.json"; -import { createEsploraFeeRateReader, createEsploraTxOutReader } from "./chainRead"; +import { + createEsploraChainTipReader, + createEsploraFeeRateReader, + createEsploraTxOutReader, +} from "./chainRead"; // The transaction read asks for /tx/:txid/raw and gets consensus bytes back. The fee read is // still JSON, which is Esplora's own shape for /fee-estimates. @@ -180,3 +184,35 @@ describe("createEsploraFeeRateReader", () => { await expect(read(1)).rejects.toThrow(); }); }); + +describe("reading how high the chain is", () => { + test("answers with the height the endpoint reports", async () => { + const read = createEsploraChainTipReader({ url: "https://esplora.example/api/" }, (async ( + url: string, + ) => { + expect(url).toBe("https://esplora.example/api/blocks/tip/height"); + + return { ok: true, text: async () => "2580990\n" }; + }) as unknown as typeof fetch); + + expect(await read()).toBe(2_580_990); + }); + + test("refuses a body that is not a height rather than returning zero", async () => { + const read = createEsploraChainTipReader({ url: "https://esplora.example/api" }, (async () => ({ + ok: true, + text: async () => "not a height", + })) as unknown as typeof fetch); + + expect(read()).rejects.toThrow(/not a block height/u); + }); + + test("refuses when the endpoint does not answer", async () => { + const read = createEsploraChainTipReader({ url: "https://esplora.example/api" }, (async () => ({ + ok: false, + status: 502, + })) as unknown as typeof fetch); + + expect(read()).rejects.toThrow(/502/u); + }); +}); diff --git a/packages/tx-manifest/src/chain/chainRead.ts b/packages/tx-manifest/src/chain/chainRead.ts index de0f004..c2da267 100644 --- a/packages/tx-manifest/src/chain/chainRead.ts +++ b/packages/tx-manifest/src/chain/chainRead.ts @@ -128,6 +128,41 @@ export function createEsploraFeeRateReader( }; } +/** + * How high the chain is, for an action whose covenant is time-locked. + * + * A contract branch guarded by `check_lock_height` reads the transaction's own locktime, and + * a transaction that declares none satisfies no such branch. What the wallet can say for + * itself is where the chain is now — the same answer every wallet writes into a locktime, and + * one that carries no knowledge of any protocol. + */ +export type ReadChainTip = () => Promise<number>; + +export function createEsploraChainTipReader( + endpoint: EsploraEndpoint, + fetchImpl: typeof fetch = fetch, +): ReadChainTip { + const base = endpoint.url.replace(/\/+$/, ""); + + return async () => { + const response = await fetchImpl(`${base}/blocks/tip/height`, { + headers: Object.fromEntries((endpoint.headers ?? []).map(({ name, value }) => [name, value])), + }); + + if (!response.ok) { + throw new Error(`Could not read the chain tip: ${response.status}`); + } + + const height = Number(await response.text()); + + if (!Number.isInteger(height) || height < 0) { + throw new Error("The chain tip came back as something that is not a block height."); + } + + return height; + }; +} + function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/packages/tx-manifest/src/chain/inputGuard.test.ts b/packages/tx-manifest/src/chain/inputGuard.test.ts index 2555703..0e77a01 100644 --- a/packages/tx-manifest/src/chain/inputGuard.test.ts +++ b/packages/tx-manifest/src/chain/inputGuard.test.ts @@ -27,7 +27,13 @@ function transaction(...spends: { issuance?: boolean; txid: string; vout: number .toReversed() .join(""); - return `${reversed}${index}00ffffffff`; + // An input declaring an issuance carries four more fields after its sequence: the + // blinding nonce, the entropy, and two confidential values — here both absent. A + // fixture that set the flag and wrote none of them was not a transaction Elements + // would produce, and the reader was built to match it. + const declared = issuance ? "00".repeat(32) + "aa".repeat(32) + "00" + "00" : ""; + + return `${reversed}${index}00ffffffff${declared}`; }) .join(""); diff --git a/packages/tx-manifest/src/chain/outputGuard.test.ts b/packages/tx-manifest/src/chain/outputGuard.test.ts new file mode 100644 index 0000000..49c2eb8 --- /dev/null +++ b/packages/tx-manifest/src/chain/outputGuard.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "bun:test"; + +import { guardBlindedOutputs } from "./outputGuard"; +import { txOutsOf } from "./txOut"; + +/** + * The guard reads bytes, so the cases are built as bytes. + * + * Each one is a real Elements transaction with one input and the outputs the case needs, and + * every output is written the way the chain writes one: an explicit amount is a `01` prefix and + * eight bytes, a hidden one is a commitment prefix and thirty-two. A fixture assembled as an + * object shaped like an answer would let this file assert something the parser could never see. + */ +const ASSET = `01${"aa".repeat(32)}`; +const HIDDEN_ASSET = `0a${"33".repeat(32)}`; +const HIDDEN_VALUE = `08${"44".repeat(32)}`; +const NONCE = `02${"55".repeat(32)}`; +const WALLET_SCRIPT = `0014${"11".repeat(20)}`; +/** One input spending an ordinary outpoint: count, txid, index, empty script, sequence. */ +const ONE_INPUT = `01${"bb".repeat(32)}0000000000ffffffff`; + +function explicit(sats: bigint, scriptHex: string): string { + const value = `01${sats.toString(16).padStart(16, "0")}`; + const length = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `${ASSET}${value}00${length}${scriptHex}`; +} + +function hidden(scriptHex: string): string { + const length = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `${HIDDEN_ASSET}${HIDDEN_VALUE}${NONCE}${length}${scriptHex}`; +} + +/** The fee: no script at all, which is how the network reads the amount it charges. */ +const FEE = explicit(500n, ""); + +function transaction(outputs: string[]): string { + const count = outputs.length.toString(16).padStart(2, "0"); + + return `02000000${"00"}${ONE_INPUT}${count}${outputs.join("")}00000000`; +} + +describe("the bytes these cases are built from", () => { + // The fixtures come first: a guard asserted against outputs the parser reads differently + // than intended would pass while proving nothing. + test("read back as the blinding each one was written with", () => { + const found = txOutsOf( + transaction([explicit(1000n, WALLET_SCRIPT), hidden(WALLET_SCRIPT), FEE]), + ); + + expect(found.ok).toBe(true); + + if (found.ok) { + expect(found.txOuts.map((txOut) => txOut.amountSats)).toEqual(["1000", undefined, "500"]); + expect(found.txOuts[2]?.scriptPubKeyHex).toBe(""); + } + }); +}); + +describe("a transaction that hides what the wallet decided to hide", () => { + test("passes when every output came back the way it was built", () => { + const built = transaction([hidden(WALLET_SCRIPT), explicit(2000n, WALLET_SCRIPT), FEE]); + + expect( + guardBlindedOutputs(built, { + changeBlinded: false, + outputs: [ + { blinded: true, id: "principal_claimed" }, + { blinded: false, id: "vault_out" }, + ], + }), + ).toEqual({ ok: true }); + }); + + // The failure this whole guard exists for. The amount is on the chain and no later step + // can take it back, so the transaction is refused rather than returned. + test("refuses when an output the protocol hides came back published", () => { + const built = transaction([explicit(1000n, WALLET_SCRIPT), FEE]); + const result = guardBlindedOutputs(built, { + changeBlinded: false, + outputs: [{ blinded: true, id: "principal_claimed" }], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toBe( + "The signed transaction publishes the amount on principal_claimed, which this action " + + "hides. Nothing is returned.", + ); + }); + + // The opposite failure, and the one that costs more later: a Simplicity program reads + // exact amounts through jets that cannot introspect a commitment, so a covenant output + // built hidden is one its own contract can never spend. + test("refuses when an output the protocol leaves open came back hidden", () => { + const built = transaction([hidden(WALLET_SCRIPT), FEE]); + const result = guardBlindedOutputs(built, { + changeBlinded: false, + outputs: [{ blinded: false, id: "vault_out" }], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toBe( + "The signed transaction hides the amount on vault_out, which this action leaves in " + + "the open. Nothing is returned.", + ); + }); + + test("names the output that disagreed rather than the first one it looked at", () => { + const built = transaction([hidden(WALLET_SCRIPT), explicit(2000n, WALLET_SCRIPT), FEE]); + const result = guardBlindedOutputs(built, { + changeBlinded: false, + outputs: [ + { blinded: true, id: "principal_claimed" }, + { blinded: true, id: "collateral_claimed" }, + ], + }); + + expect(result.ok ? "" : result.reason).toContain("collateral_claimed"); + }); +}); + +describe("the change the module appends for itself", () => { + // Change is not among the outputs the wallet adds — the module works out what is left + // after the fee and appends it — so it is checked by where it lands rather than by name. + test("is checked against what the wallet decided for it", () => { + const built = transaction([explicit(1000n, WALLET_SCRIPT), hidden(WALLET_SCRIPT), FEE]); + + expect( + guardBlindedOutputs(built, { + changeBlinded: true, + outputs: [{ blinded: false, id: "vault_out" }], + }), + ).toEqual({ ok: true }); + }); + + test("refuses when it came back published and nothing said to publish it", () => { + const built = transaction([explicit(1000n, WALLET_SCRIPT), explicit(400n, WALLET_SCRIPT), FEE]); + const result = guardBlindedOutputs(built, { + changeBlinded: true, + outputs: [{ blinded: false, id: "vault_out" }], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("the change"); + }); + + // A transaction short enough that the module drops the change and pays what is left as + // fee. There is nothing to check and nothing to refuse. + test("is not looked for when the transaction carries none", () => { + const built = transaction([explicit(1000n, WALLET_SCRIPT), FEE]); + + expect( + guardBlindedOutputs(built, { + changeBlinded: true, + outputs: [{ blinded: false, id: "vault_out" }], + }), + ).toEqual({ ok: true }); + }); +}); + +describe("what the guard will not let through", () => { + test("a fee whose amount is hidden, which no network can read", () => { + const built = transaction([explicit(1000n, WALLET_SCRIPT), hidden("")]); + const result = guardBlindedOutputs(built, { + changeBlinded: false, + outputs: [{ blinded: false, id: "vault_out" }], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("hides the fee"); + }); + + // Fewer outputs than the wallet built is a difference like any other: a transaction + // missing an output is not a safer version of the one a person approved. + test("a transaction carrying fewer outputs than the action built", () => { + const built = transaction([explicit(1000n, WALLET_SCRIPT)]); + const result = guardBlindedOutputs(built, { + changeBlinded: false, + outputs: [ + { blinded: false, id: "vault_out" }, + { blinded: true, id: "principal_claimed" }, + ], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("carries 1 outputs and this action built 2"); + }); + + test("bytes that are not a transaction", () => { + expect(guardBlindedOutputs("not hex", { changeBlinded: false, outputs: [] }).ok).toBe(false); + }); + + // An action that declares no outputs never reaches here, so this is about the guard + // rather than about a document: with nothing expected, every output is change. + test("an empty expectation still checks the fee and the change", () => { + expect( + guardBlindedOutputs(transaction([hidden(WALLET_SCRIPT), FEE]), { + changeBlinded: true, + outputs: [], + }), + ).toEqual({ ok: true }); + }); +}); diff --git a/packages/tx-manifest/src/chain/outputGuard.ts b/packages/tx-manifest/src/chain/outputGuard.ts new file mode 100644 index 0000000..d43fe99 --- /dev/null +++ b/packages/tx-manifest/src/chain/outputGuard.ts @@ -0,0 +1,102 @@ +import { txOutsOf } from "./txOut"; + +export type OutputGuardResult = { ok: true } | { ok: false; reason: string }; + +/** One output the wallet built, and what it decided that output does with its amount. */ +export type ExpectedBlinding = { + /** Whether the wallet decided this output hides what it carries. */ + blinded: boolean; + /** The manifest's id for it, so a refusal can name the one that came back wrong. */ + id: string; +}; + +export type ExpectedOutputs = { + /** Whether the change this transaction returns was to hide what it carries. */ + changeBlinded: boolean; + /** The action's own outputs, in the order the wallet added them. */ + outputs: ExpectedBlinding[]; +}; + +/** + * Checks the finished transaction hides exactly what the wallet decided to hide. + * + * Whether an output hides its amount is settled while the document is read, and carried from + * there to a signing module that has never read the document. The module is told with a + * blinding key or the absence of one, and what it does with that is not visible from the call: + * the answer is in the bytes that come back, where a hidden amount is a commitment and an open + * one is a number. + * + * Nothing checked that until now, and the two failures are opposite and both silent. An output + * meant to be hidden that comes back open publishes an amount the protocol meant kept, and + * nothing later in the transaction's life can put it back. An output meant to be open that + * comes back hidden is worse in a different way: a Simplicity program reads exact amounts + * through jets that cannot introspect a commitment, so a covenant output built hidden is one + * its own contract can never spend, and the money is not lost until someone tries. + * + * So this is the sibling of the input guard and is written the same way — an expected set + * against the transaction's own bytes, refusing on difference — rather than trusting that a + * blinding key handed over was a blinding key applied. + */ +export function guardBlindedOutputs( + transactionHex: string, + expected: ExpectedOutputs, +): OutputGuardResult { + const observed = txOutsOf(transactionHex); + + if (!observed.ok) { + return { ok: false, reason: observed.reason }; + } + + if (observed.txOuts.length < expected.outputs.length) { + return { + ok: false, + reason: + `The signed transaction carries ${observed.txOuts.length} outputs and this action ` + + `built ${expected.outputs.length}. Nothing is returned.`, + }; + } + + for (const [at, declared] of expected.outputs.entries()) { + // Present only when the amount is a number rather than a commitment, which is the + // question being asked. Read from the transaction rather than from anything that + // claims to describe it. + const hidden = observed.txOuts[at]?.amountSats === undefined; + + if (hidden !== declared.blinded) { + return { ok: false, reason: disagreement(declared.id, declared.blinded) }; + } + } + + for (const txOut of observed.txOuts.slice(expected.outputs.length)) { + // The fee is the one output a person never sees a script for: the network reads its + // amount to charge it, so it is always in the open and it is the only output allowed + // to be. Everything else after the action's own outputs is change. + if (txOut.scriptPubKeyHex === "") { + if (txOut.amountSats === undefined) { + return { + ok: false, + reason: + "The signed transaction hides the fee it pays, which no network can read. " + + "Nothing is returned.", + }; + } + + continue; + } + + if ((txOut.amountSats === undefined) !== expected.changeBlinded) { + return { ok: false, reason: disagreement("the change", expected.changeBlinded) }; + } + } + + return { ok: true }; +} + +/** What a person is told when one output came back the opposite way round. */ +function disagreement(what: string, wasToBeHidden: boolean): string { + return wasToBeHidden + ? `The signed transaction publishes the amount on ${what}, which this action hides. ` + + "Nothing is returned." + : `The signed transaction hides the amount on ${what}, which this action leaves in the ` + + "open. Nothing is returned."; +} diff --git a/packages/tx-manifest/src/chain/spentInputs.test.ts b/packages/tx-manifest/src/chain/spentInputs.test.ts new file mode 100644 index 0000000..6841fe8 --- /dev/null +++ b/packages/tx-manifest/src/chain/spentInputs.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; + +import { spentInputs } from "./spentInputs"; + +/** + * Reading which outpoints a finished transaction spends. + * + * The reader walks the bytes rather than asking the module that built them, which is the whole + * point of the guard it feeds. That makes the walk itself load-bearing: a field it does not know + * about does not raise, it shifts everything after it, and the guard then refuses a transaction + * for spending an outpoint nobody built. + */ + +const TXID_A = "11".repeat(32); +const TXID_B = "22".repeat(32); + +/** One input, with the issuance fields Elements writes after the sequence when it declares one. */ +function input(txidHex: string, vout: number, issuance?: { explicitAmount: boolean }) { + const flagged = issuance === undefined ? vout : vout | 0x8000_0000; + const index = new Uint8Array(4); + + new DataView(index.buffer).setUint32(0, flagged >>> 0, true); + + const parts = [ + txidHex, // serialised reversed, and reversed back on the way out + [...index].map((byte) => byte.toString(16).padStart(2, "0")).join(""), + "00", // empty scriptSig + "ffffffff", // sequence + ]; + + if (issuance) { + parts.push( + "00".repeat(32), // asset blinding nonce + "aa".repeat(32), // asset entropy + issuance.explicitAmount ? `01${"00".repeat(7)}02` : "00", // amount: explicit, or absent + "00", // inflation keys: absent + ); + } + + return parts.join(""); +} + +function transaction(...inputs: string[]) { + return ["02000000", "01", inputs.length.toString(16).padStart(2, "0"), ...inputs].join(""); +} + +describe("the outpoints a signed transaction spends", () => { + test("reads an ordinary input", () => { + const result = spentInputs(transaction(input(TXID_A, 3))); + + expect(result).toEqual({ ok: true, spent: [{ txid: TXID_A, vout: 3 }] }); + }); + + /* + * An input that creates an asset carries four more fields, written after the sequence and + * announced by the top bit of the index. Walking past them as though they were the next + * input's outpoint is what made a two-input transaction read as spending all-zeroes — and the + * guard, correctly, refused a transaction the wallet had built itself. + */ + test("reads the input after one that creates an asset", () => { + const result = spentInputs( + transaction(input(TXID_A, 0, { explicitAmount: true }), input(TXID_B, 1)), + ); + + expect(result).toEqual({ + ok: true, + spent: [ + { txid: TXID_A, vout: 0 }, + { txid: TXID_B, vout: 1 }, + ], + }); + }); + + test("reads one whose issuance amounts are absent rather than explicit", () => { + const result = spentInputs( + transaction(input(TXID_A, 0, { explicitAmount: false }), input(TXID_B, 2)), + ); + + expect(result).toEqual({ + ok: true, + spent: [ + { txid: TXID_A, vout: 0 }, + { txid: TXID_B, vout: 2 }, + ], + }); + }); + + test("says so rather than guessing when the bytes end early", () => { + const result = spentInputs( + transaction(input(TXID_A, 0, { explicitAmount: true })).slice(0, 60), + ); + + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/tx-manifest/src/chain/spentInputs.ts b/packages/tx-manifest/src/chain/spentInputs.ts index 054adc1..d92daab 100644 --- a/packages/tx-manifest/src/chain/spentInputs.ts +++ b/packages/tx-manifest/src/chain/spentInputs.ts @@ -1,4 +1,15 @@ -import { decodeHex, readReversedHex, readUint32, readVarint, skip } from "./bytes"; +import { + decodeHex, + type Reader, + readReversedHex, + readUint8, + readUint32, + readVarint, + skip, +} from "./bytes"; + +/** The top bit of an input's index, which says four more fields follow its sequence. */ +const ISSUANCE_FLAG = 0x8000_0000; /** One transaction input, as the outpoint it spends. */ export type OutPoint = { txid: string; vout: number }; @@ -54,8 +65,50 @@ export function spentInputs(transactionHex: string): SpentInputs { // Elements marks issuance and peg-in in the top two bits of the index rather than in // a field of their own, so the index has to be unmasked before it means anything. + if ((vout & ISSUANCE_FLAG) !== 0 && !skipIssuance(reader)) { + return { ok: false, reason: `The signed transaction ends inside input ${index}.` }; + } + spent.push({ txid, vout: vout & 0x3f_ff_ff_ff }); } return { ok: true, spent }; } + +/** + * Walks past the issuance an input declares: two 32-byte fields and two amounts. + * + * An amount here is a confidential value, which is one byte saying what it is and then that + * many more — nothing, eight bytes in the open, or a thirty-two byte commitment. Skipping a + * fixed width instead would work until the first transaction that hid one, and then it would + * not fail: it would read the next input's outpoint out of the middle of this one. + */ +function skipIssuance(reader: Reader): boolean { + // The blinding nonce and the entropy, both fixed. + if (!skip(reader, 64)) { + return false; + } + + return skipConfidentialValue(reader) && skipConfidentialValue(reader); +} + +/** One confidential value: absent, explicit, or committed to. */ +function skipConfidentialValue(reader: Reader): boolean { + const prefix = readUint8(reader); + + if (prefix === undefined) { + return false; + } + + if (prefix === 0x00) { + return true; + } + + if (prefix === 0x01) { + return skip(reader, 8); + } + + // 0x08 and 0x09 are the two parities a commitment is written with; anything else is not a + // value this reader knows, and guessing its width would put every later input somewhere else. + return (prefix === 0x08 || prefix === 0x09) && skip(reader, 32); +} diff --git a/packages/tx-manifest/src/chain/txOut.ts b/packages/tx-manifest/src/chain/txOut.ts index 9cf7e92..706b6ff 100644 --- a/packages/tx-manifest/src/chain/txOut.ts +++ b/packages/tx-manifest/src/chain/txOut.ts @@ -1,5 +1,5 @@ /** - * Reading one output out of a transaction's own bytes. + * Reading a transaction's outputs out of its own bytes — one of them, or all of them. * * The wallet needs three things about a covenant output before it will spend it: the script * that locks it, so the rebuilt contract can be compared against it; its amount and asset, @@ -36,16 +36,82 @@ export type TxOutAt = { ok: true; txOut: ParsedTxOut } | { ok: false; reason: st * reports something well-formed and wrong. */ export function txOutAt(transactionHex: string, vout: number): TxOutAt { - const bytes = decodeHex(transactionHex); + const found = readerAtOutputs(transactionHex); - if (!bytes) { - return { ok: false, reason: "The transaction is not hex." }; + if (!found.ok) { + return { ok: false, reason: found.reason }; } if (!Number.isInteger(vout) || vout < 0) { return { ok: false, reason: `Not an output index: ${vout}` }; } + if (BigInt(vout) >= found.outputCount) { + return { ok: false, reason: `The transaction has no output at index ${vout}.` }; + } + + for (let index = 0; index < vout; index += 1) { + if (!skipOutput(found.reader)) { + return { ok: false, reason: `The transaction ends inside output ${index}.` }; + } + } + + const txOut = readOutput(found.reader); + + return txOut === undefined + ? { ok: false, reason: `The transaction ends inside output ${vout}.` } + : { ok: true, txOut }; +} + +export type TxOutsOf = { ok: true; txOuts: ParsedTxOut[] } | { ok: false; reason: string }; + +/** + * Every output of a consensus-encoded Elements transaction, in the order it carries them. + * + * Reading one output answers a question about one outpoint. Reading all of them answers a + * question about the transaction, which is what checking a finished transaction against what + * was agreed to takes: whether an output landed where it was put, and whether it came back + * carrying what it was built to carry, are both questions about positions rather than about a + * position. Walking once is also the difference between one pass and one pass per output. + */ +export function txOutsOf(transactionHex: string): TxOutsOf { + const found = readerAtOutputs(transactionHex); + + if (!found.ok) { + return { ok: false, reason: found.reason }; + } + + const txOuts: ParsedTxOut[] = []; + + for (let index = 0n; index < found.outputCount; index += 1n) { + const txOut = readOutput(found.reader); + + if (txOut === undefined) { + return { ok: false, reason: `The transaction ends inside output ${index}.` }; + } + + txOuts.push(txOut); + } + + return { ok: true, txOuts }; +} + +type AtOutputs = { ok: false; reason: string } | { ok: true; outputCount: bigint; reader: Reader }; + +/** + * A reader positioned at the first output, and how many follow. + * + * Walks the whole input list to get there, issuance data included — an input that issues an + * asset carries four more fields, and a reader that does not know that lands mid-output and + * reports something well-formed and wrong. + */ +function readerAtOutputs(transactionHex: string): AtOutputs { + const bytes = decodeHex(transactionHex); + + if (!bytes) { + return { ok: false, reason: "The transaction is not hex." }; + } + const reader: Reader = { at: 0, bytes }; // Version, then the marker Elements uses to say whether witness data follows. @@ -67,20 +133,13 @@ export function txOutAt(transactionHex: string, vout: number): TxOutAt { const outputCount = readVarint(reader); - if (outputCount === undefined) { - return { ok: false, reason: "The transaction declares no output count." }; - } - - if (BigInt(vout) >= outputCount) { - return { ok: false, reason: `The transaction has no output at index ${vout}.` }; - } - - for (let index = 0; index < vout; index += 1) { - if (!skipOutput(reader)) { - return { ok: false, reason: `The transaction ends inside output ${index}.` }; - } - } + return outputCount === undefined + ? { ok: false, reason: "The transaction declares no output count." } + : { ok: true, outputCount, reader }; +} +/** One output, read from wherever the reader currently sits. */ +function readOutput(reader: Reader): ParsedTxOut | undefined { const start = reader.at; const asset = readField(reader, 32); const value = readField(reader, 8); @@ -93,25 +152,22 @@ export function txOutAt(transactionHex: string, vout: number): TxOutAt { nonce === undefined || scriptLength === undefined ) { - return { ok: false, reason: `The transaction ends inside output ${vout}.` }; + return undefined; } const scriptPubKeyHex = readHex(reader, Number(scriptLength)); if (scriptPubKeyHex === undefined) { - return { ok: false, reason: `The transaction ends inside output ${vout}.` }; + return undefined; } return { - ok: true, - txOut: { - // An asset id is written in reverse of how it is displayed, and every consumer here - // wants the displayed form. - ...(asset.explicit ? { rawAssetId: reverseHex(asset.body) } : {}), - ...(value.explicit ? { amountSats: String(bigEndian(value.body)) } : {}), - scriptPubKeyHex, - txOutHex: encodeHex(bytes.slice(start, reader.at)), - }, + // An asset id is written in reverse of how it is displayed, and every consumer here + // wants the displayed form. + ...(asset.explicit ? { rawAssetId: reverseHex(asset.body) } : {}), + ...(value.explicit ? { amountSats: String(bigEndian(value.body)) } : {}), + scriptPubKeyHex, + txOutHex: encodeHex(reader.bytes.slice(start, reader.at)), }; } diff --git a/packages/tx-manifest/src/confirmation/index.ts b/packages/tx-manifest/src/confirmation/index.ts index a12abb5..0cf3028 100644 --- a/packages/tx-manifest/src/confirmation/index.ts +++ b/packages/tx-manifest/src/confirmation/index.ts @@ -1,5 +1,10 @@ import { asRecord } from "../document/json"; import type { NormalisedAction, NormalisedManifest } from "../document/normalise"; +import { + type BlindingDecision, + describeBlinding, + describePublishedChange, +} from "../evaluation/blinding"; import type { ManifestReview } from "../review"; import { computed, fromSite, map, type Origin, type Provenanced, verified } from "./provenance"; @@ -11,6 +16,41 @@ export type NetEffect = { sats: Provenanced<bigint>; }; +/** + * One amount this transaction keeps off the chain, and whose word decided that. + * + * A person approving an action is agreeing to what it moves and to how much of that is + * published. The second half was decided while the document was read and then went nowhere: + * the builder needs only a yes or no, so the word behind it was worked out and dropped. Which + * left the wallet hiding amounts on someone's behalf without ever saying so, and unable to + * tell them apart — a protocol that asked, and a protocol that said nothing where this + * network's own answer is to hide. + */ +export type HiddenAmount = { + /** Whose word decided it, in a person's own terms rather than the format's. */ + decidedBy: Provenanced<string>; + /** What the protocol calls this output. Its own word, so its own provenance. */ + id: Provenanced<string>; +}; + +/** + * One amount this transaction publishes that the format would have kept off the chain. + * + * There is one rule that produces these and it applies to one thing: a contract action's own + * change. The wallet publishes it so the money returns in a form the next action can be funded + * from, and publishing it is a deviation from the format rather than an application of it. + * + * Which is exactly why it is on the screen. A person has already been told whose word decided + * each amount the wallet hid; a wallet that then quietly overrode a protocol, in the one place + * that person was told to trust its reading, would have made that first sentence worth less. + */ +export type PublishedAmount = { + /** What the protocol calls this output, or the wallet's own name for its change. */ + id: Provenanced<string>; + /** The word that was set aside and what publishing bought, in a person's own terms. */ + reason: Provenanced<string>; +}; + /** One covenant the action touches, and what the wallet established about it. */ export type CovenantRow = { address: Provenanced<string>; @@ -38,12 +78,26 @@ export type ConfirmationModel = { /** The action's name, as the manifest spells it. */ action: Provenanced<string>; covenants: CovenantRow[]; + /** + * The asset the network charges its fee in, which is the one asset it can be charged in. + * + * Shown so the surface can tell the row it knows how to write from the rows it does not: a + * wallet knows what the network's own asset is called and how it is divided, and knows + * neither of those about a token a protocol invented. Printing a token's units under the + * network asset's name is the failure this prevents, and it is the failure a single-asset + * screen could not have. + */ + feeAsset: Provenanced<string>; /** What the wallet worked out this will cost, which is not the figure that gets charged. */ feeSats: Provenanced<bigint>; + /** Every amount this transaction hides, each with whose word decided that. */ + hiddenAmounts: HiddenAmount[]; /** The wallet's own balance change per asset — the authoritative figure. */ netEffect: NetEffect[]; /** The protocol's name, which is the site's word for itself. */ protocol: Provenanced<string>; + /** Every amount this wallet published over the format's word, each saying which word. */ + publishedAmounts: PublishedAmount[]; /** The protocol's own one-line summary, when it wrote one. Site text, always. */ summary?: Provenanced<string>; }; @@ -52,10 +106,10 @@ export type ConfirmationModel = { * Builds what the person sees from what the wallet established. * * The net effect is the wallet's own balance change rather than the manifest's declared - * amounts. A covenant input the wallet does not own is not part of that change and is not - * counted into it; those appear as their own rows, which is what the reference - * implementation does and for the same reason — the declared amounts are the site's account - * of the transaction, and the balance change is what actually happens to this person. + * amounts, and there is one of them per asset. A covenant input the wallet does not own is not + * part of that change directly; it appears as its own row, which is what the reference + * implementation does and for the same reason — the declared amounts are the site's account of + * the transaction, and the balance change is what actually happens to this person. */ export function confirmationModel( review: ManifestReview, @@ -63,7 +117,6 @@ export function confirmationModel( action: NormalisedAction, input: { accountLabel: string; policyAsset: string }, ): ConfirmationModel { - const paid = review.outputs.reduce((total, output) => total + output.sats, 0n); const summary = actionSummary(action); return { @@ -75,21 +128,81 @@ export function confirmationModel( utxoType: fromSite(found.utxoType), verified: computed(found.verified === "matches-chain"), })), + feeAsset: computed(input.policyAsset.trim().toLowerCase()), // Computed rather than chain-read: it is the wallet's own estimate of a figure the // network will settle, and calling it anything stronger would overstate it. feeSats: computed(review.estimatedFeeSats), - netEffect: [ - { - asset: computed(input.policyAsset), - // Negative: everything the action pays out leaves this wallet, and the fee with it. - sats: computed(-(paid + review.estimatedFeeSats)), - }, - ], + hiddenAmounts: hiddenAmounts(review), + // One line per asset the action moves, and no line that adds two of them together. A + // transaction swapping a token for money changes this wallet's balance in two directions + // at once, and a single figure can only say one of them — which is why what was here + // before could report a payout for an action that pays this wallet. + netEffect: review.movements.map((movement) => ({ + asset: computed(movement.asset), + sats: computed(movement.sats), + })), protocol: fromSite(manifest.protocol ?? ""), + publishedAmounts: publishedAmounts(review), ...(summary === undefined ? {} : { summary: fromSite(summary) }), }; } +/** + * Every amount this action keeps off the chain. + * + * Change is not among them and no longer can be: the wallet publishes a contract action's own + * change so it comes back spendable, and says so on the list below instead. It was on this list + * until then, for the reason a person is least likely to have expected — the usual thing a + * document says about its change is nothing, and on this network that silence means hidden. + * + * The sentence is the wallet's own reading of the document, so it is computed; the name beside + * it is the protocol's word for the output, so it is the site's. That split is the point: the + * word "hidden" here was established by this wallet, and what is being hidden was not. + */ +function hiddenAmounts(review: ManifestReview): HiddenAmount[] { + return review.outputs + .filter((output) => output.blinded) + .map((output) => ({ + decidedBy: computed(word(output.decidedBy)), + id: fromSite(output.id), + })); +} + +/** + * Every amount this wallet published where the format would have hidden it. + * + * A contract action's own change and nothing else, which is the whole of the deviation. An + * action's change in a token the wallet builds itself comes first under the document's own name + * for it; the network asset's change comes last and is named rather than given a document's id, + * because the module appends that one and the wallet would be inventing a name. + * + * Empty when a protocol asked for its change in the open itself. Then the wallet overrode + * nothing and has nothing to answer for. + */ +function publishedAmounts(review: ManifestReview): PublishedAmount[] { + const published = review.outputs + .filter((output) => output.overrode !== undefined) + .map((output) => ({ + id: fromSite(output.id), + reason: computed(describePublishedChange(output.overrode)), + })); + + return review.changeOverrode === undefined + ? published + : [ + ...published, + { + id: computed("change"), + reason: computed(describePublishedChange(review.changeOverrode)), + }, + ]; +} + +/** The sentence for one decision, which is the refusal's sentence and for the same reason. */ +function word(decidedBy: BlindingDecision["decidedBy"] | undefined): string { + return describeBlinding({ blinding: "hidden", decidedBy: decidedBy ?? "chain" }); +} + /** * What a person is told about a value's origin, in their own terms. * diff --git a/packages/tx-manifest/src/corpus.test.ts b/packages/tx-manifest/src/corpus.test.ts index f4b87dd..23fc4cf 100644 --- a/packages/tx-manifest/src/corpus.test.ts +++ b/packages/tx-manifest/src/corpus.test.ts @@ -8,6 +8,8 @@ import lendingV3 from "./__fixtures__/lending_v3.manifest.json"; import p2pkGrouped from "./__fixtures__/p2pk-grouped.manifest.json"; import p2pk from "./__fixtures__/p2pk.manifest.json"; import zeroconf from "./__fixtures__/zeroconf.manifest.json"; +import { identifiedForeignAsset, refuseUnfundableAsset, statedAsset } from "./document/asset"; +import { asArray, asRecord } from "./document/json"; import { findAction, normaliseManifest } from "./document/normalise"; import { refuseUnsupported } from "./document/refuse"; import { ignored, inspectConstructs, loadBearing } from "./document/registry"; @@ -67,21 +69,26 @@ describe("every refusal a published manifest earns is named, not only described" }); } - // The measurement this bundle exists to move. Every published protocol used to be refused - // on a construct this wallet did not implement; none is now. What is left is a different - // statement about the wallet rather than about its reading: three protocols move an asset - // it does not move, and the oldest generation asks for a witness it cannot produce. + // The measurement this bundle exists to move. Every published protocol used to be refused on + // a construct this wallet did not implement; then three of them were refused on the asset + // they move. Neither holds now, and the second was never true in the first place: the asset + // rule compared text against the network's own asset and refused anything else, and every + // asset in this corpus is written as a lookup rather than as an id, so it refused the + // spelling of a question the document had not answered yet. See the asset describe below. + // + // What is left is one statement about the wallet rather than about its reading: the oldest + // lending generation asks for a witness it cannot produce. test("and no published manifest is refused for a construct any more", () => { const named = Object.fromEntries( (Object.keys(CORPUS) as (keyof typeof CORPUS)[]).map((name) => [name, rejectionOf(name)]), ); expect(named).toEqual({ - dex: "foreign-asset", + dex: undefined, last_will: undefined, lending: "unproducible-witness", - lending_v2: "foreign-asset", - lending_v3: "foreign-asset", + lending_v2: undefined, + lending_v3: undefined, p2pk: undefined, zeroconf: undefined, }); @@ -180,18 +187,23 @@ describe("what this wallet can do with each published protocol", () => { expect(refusalFor("zeroconf")).toBe(""); }); - // Recorded as the measurement rather than as an expectation: what stops each of the three - // still refused is now the asset it moves or the witness it asks for, and no longer a part + // Recorded as the measurement rather than as an expectation. One protocol is still refused + // from its document alone, and what stops it is the witness it asks for rather than a part // of the document this wallet had not read. - test.each([ - ["dex", "moves"], - ["lending", "witness"], - ["lending_v2", "moves"], - ["lending_v3", "moves"], - ])("%s refuses, and not on a construct", (name, because) => { - expect(refusalFor(name)).toContain(because); + test("lending refuses, on the witness it asks for and not on a construct", () => { + expect(refusalFor("lending")).toContain("witness"); }); + // The three that used to be refused on the asset they move. Named rather than counted: each + // is a protocol whose document this wallet now reads to the end, and a document dropping + // back out of this list is the asset rule reading a lookup as an id again. + test.each([["dex"], ["lending_v2"], ["lending_v3"]])( + "%s is read to the end, where its asset used to stop it", + (name) => { + expect(refusalFor(name)).toBe(""); + }, + ); + test("last_will is read and built, where it was refused before this bundle", () => { expect(refusalFor("last_will")).toBe(""); }); @@ -213,6 +225,138 @@ describe("what this wallet can do with each published protocol", () => { }); }); +// AC-06. The rule that used to refuse three of these protocols, and the rule that replaced it. +// +// The old check asked whether an action's asset text was the network's own and refused every +// other string. That is the same question as "does this move an asset this wallet cannot move" +// only when a document writes its assets as ids — and these documents do not. So the check was +// answering about the spelling, and its verdict on real money was never reached. +// +// These record the corpus fact the rule turned on, and where the protection went instead. +describe("the asset a document states, and the asset it defers", () => { + /** Every asset text a protocol declares, at an input or an output. */ + function declaredAssets(name: string): string[] { + const found: string[] = []; + + for (const action of normalised(name).manifest.actions) { + for (const kind of ["inputs", "outputs"] as const) { + for (const entry of asArray(action.node[kind])) { + const asset = asRecord(entry)?.asset; + + if (typeof asset === "string") { + found.push(asset); + } + } + } + } + + return found; + } + + // The fact the old rule broke on. Every asset in every published protocol is either the + // network's own or a lookup resolved against a file the document does not carry. Not one is + // an id, so a rule refusing "anything that is not the network's asset" refused a lookup + // every single time it fired. + test("no published protocol writes an asset as an id, anywhere", () => { + const kinds = new Set( + Object.keys(CORPUS).flatMap((name) => + declaredAssets(name).map((asset) => statedAsset(asset, POLICY_ASSET).kind), + ), + ); + + expect([...kinds].toSorted()).toEqual(["deferred", "network"]); + }); + + // And so nothing in the corpus earns the document-level refusal, which is what changed. + test("and so none of them names an asset this wallet cannot move", () => { + const named = Object.fromEntries( + Object.keys(CORPUS).map((name) => [ + name, + normalised(name) + .manifest.actions.map((action) => identifiedForeignAsset(action, POLICY_ASSET)) + .find((found) => found !== undefined), + ]), + ); + + expect(Object.values(named).every((found) => found === undefined)).toBe(true); + }); + + // The refusal was moved rather than dropped. A document that does commit to an asset still + // earns it from the document alone — written here rather than found in the corpus, because + // the corpus contains no such document, which is the whole point above. + test("a document that does name a foreign asset is still refused, from the document alone", () => { + const { manifest } = normaliseManifest({ + actions: { + Move: { + outputs: [ + { + amount_sat: 1000, + asset: "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d", + destination: "wallet", + id: "out_one", + }, + ], + }, + }, + chain: "liquid", + }); + const refusal = refuseUnsupported(manifest, { + compilerVersion: "0.6.0", + contractSources: {}, + policyAsset: POLICY_ASSET, + }); + + expect(refusal?.reject).toBe("foreign-asset"); + }); + + // Where the protection went. lending_v3 states its assets as lookups into a deployment, so + // the document cannot say what they are — but once a deployment supplies them, this wallet + // still will not fund an action in an asset it does not hold, and says so by name. + test("and lending_v3 is still refused once its lookups resolve to an asset this wallet lacks", () => { + const action = findAction(normalised("lending_v3").manifest, "CreateFactory"); + + if (!action) { + throw new Error("lending_v3 declares no CreateFactory action"); + } + + const refusal = refuseUnfundableAsset(action, POLICY_ASSET, { + instance: { + BORROWER_NFT_ASSET_ID: "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d", + COLLATERAL_ASSET_ID: "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d", + FACTORY_ASSET_ID: "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d", + LENDER_NFT_ASSET_ID: "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d", + PRINCIPAL_ASSET_ID: "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d", + }, + params: {}, + }); + + expect(refusal).toContain("funds an action only in the network's own asset"); + }); + + // And the same action is fundable when the deployment's assets are the network's own, which + // is what makes the check above a check rather than a second blanket refusal. + test("and it is not refused when those same lookups resolve to the network's own asset", () => { + const action = findAction(normalised("lending_v3").manifest, "CreateFactory"); + + if (!action) { + throw new Error("lending_v3 declares no CreateFactory action"); + } + + const refusal = refuseUnfundableAsset(action, POLICY_ASSET, { + instance: { + BORROWER_NFT_ASSET_ID: POLICY_ASSET, + COLLATERAL_ASSET_ID: POLICY_ASSET, + FACTORY_ASSET_ID: POLICY_ASSET, + LENDER_NFT_ASSET_ID: POLICY_ASSET, + PRINCIPAL_ASSET_ID: POLICY_ASSET, + }, + params: {}, + }); + + expect(refusal).toBeUndefined(); + }); +}); + // AC-02's decorative half across the corpus: what is ignored is reported as ignored. describe("what each protocol says that this wallet reads past", () => { test("p2pk's attestation_version, which no implementation reads", () => { diff --git a/packages/tx-manifest/src/covenants/compileParams.test.ts b/packages/tx-manifest/src/covenants/compileParams.test.ts index f13f1e9..4602872 100644 --- a/packages/tx-manifest/src/covenants/compileParams.test.ts +++ b/packages/tx-manifest/src/covenants/compileParams.test.ts @@ -96,6 +96,98 @@ describe("resolveCompileParams", () => { }); }); +/** + * A covenant's parameters come off the deployment it belongs to, and the corpus wires them by + * bare name: `{"ASSET_B": "ASSET_B"}` on a swap's offer covenant, `{"ISSUING_UTXOS_COUNT": + * "ISSUING_UTXOS_COUNT"}` on a lending protocol's factory. Neither names a parameter of the + * action being run — a constructor supplied those once, and every action afterwards reads them + * back off the deployment it created. + */ +describe("a parameter the deployment holds rather than the request", () => { + test("resolves off the deployment when the request has no such name", () => { + const result = resolveCompileParams( + { PUB_KEY: "OWNER" }, + { OWNER: "pubkey" }, + scope({}, { OWNER: PUBKEY }), + ); + + expect(result).toEqual({ + arguments: { PUB_KEY: { type: "Pubkey", value: `0x${PUBKEY}` } }, + ok: true, + }); + }); + + // A value the request chose is not the deployment's to overwrite, which is the order every + // other reader of a bare name uses. + test("but the request wins where both hold the name", () => { + const other = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const result = resolveCompileParams( + { PUB_KEY: "OWNER" }, + { OWNER: "pubkey" }, + scope({ OWNER: PUBKEY }, { OWNER: other }), + ); + + expect(result).toMatchObject({ arguments: { PUB_KEY: { value: `0x${PUBKEY}` } } }); + }); + + // Reaching the deployment is not permission to encode what is found there. The value still + // has to have been declared with a type, or the address would be built on a guessed width. + test("and is still refused when nothing declares its type", () => { + const result = resolveCompileParams({ PUB_KEY: "OWNER" }, {}, scope({}, { OWNER: PUBKEY })); + + expect(result).toMatchObject({ ok: false }); + }); + + test("a name neither holds is refused naming the name, as it always was", () => { + const result = resolveCompileParams({ PUB_KEY: "OWNER" }, { OWNER: "pubkey" }, scope({}, {})); + + expect(result.ok ? "" : result.reason).toContain("OWNER"); + }); +}); + +/** + * The types that arrived with this slice, at the site that uses them. What each one encodes to + * is proved in `paramEncoding.test.ts`; what is here is that the wiring reaches them. + */ +describe("the types a live deployment declares", () => { + const ASSET = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec8ef5b4d5"; + + test("an asset id, an amount, a height and a count", () => { + const result = resolveCompileParams( + { + ASSET_B: "ASSET_B", + ISSUING_UTXOS_COUNT: "params.count", + MAX_FEE: "MAX_FEE", + TIMEOUT: "TIMEOUT", + }, + { ASSET_B: "liquid.asset_id", MAX_FEE: "u64", TIMEOUT: "u32", count: "u8" }, + scope({ count: "2" }, { ASSET_B: ASSET, MAX_FEE: "5000", TIMEOUT: "900000" }), + ); + + expect(result).toMatchObject({ + arguments: { + ASSET_B: { type: "u256" }, + ISSUING_UTXOS_COUNT: { type: "u8", value: "2" }, + MAX_FEE: { type: "u64", value: "5000" }, + TIMEOUT: { type: "u32", value: "900000" }, + }, + ok: true, + }); + }); + + test("and an address, which is refused by name because nothing says what it encodes to", () => { + const result = resolveCompileParams( + { PAYEE: "PAYEE" }, + { PAYEE: "address" }, + scope({}, { PAYEE: "ex1pg45gz7zucl2krj42qk0q9udzsgcxd0vxqs3ej6l286fvvgdmqe9s5w0cfg" }), + ); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("address"); + expect(result.ok ? "" : result.reason).toContain("rendering of a locking script"); + }); +}); + // The deployed lending contracts take boolean parameters — asset_auth a burn flag, // asset_auth_vault three of them — so a manifest wiring one has to be encodable. describe("boolean compile parameters", () => { @@ -131,12 +223,21 @@ describe("boolean compile parameters", () => { ).toMatchObject({ arguments: { B: { value: "false" } } }); }); - // Anything else goes through unchanged so the compiler names the type, rather than being - // turned into `false` here — which would be a different covenant at a different address. - test("anything else is left for the compiler to refuse by name", () => { - expect( - resolveCompileParams({ B: "params.burn" }, { burn: "bool" }, scope({ burn: "maybe" })), - ).toMatchObject({ arguments: { B: { value: "maybe" } } }); + // Anything else is refused rather than turned into `false`, which would be a different + // covenant at a different address. It used to be handed to the compiler, which refused it + // too — at a character position, in a message naming neither the compile parameter nor the + // reference. Refusing it here says all four things the person filling the request can act + // on, the same way a key of the wrong width already did. + test("anything else is refused, naming what was wired and what was wanted", () => { + const result = resolveCompileParams( + { B: "params.burn" }, + { burn: "bool" }, + scope({ burn: "maybe" }), + ); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("B is wired to params.burn"); + expect(result.ok ? "" : result.reason).toContain("true or false"); }); }); @@ -180,3 +281,86 @@ describe("a value that cannot be its declared type", () => { expect(resolve(`0x${key}`).ok).toBe(true); }); }); + +/** + * The wiring some deployments write: a bare value where every other parameter has a name. + * + * A value declares nothing at that position, so the only thing that can type it is the contract + * it is being compiled into. These prove the two are joined, and that a name still wins wherever + * one resolves. + */ +describe("a compile parameter written as a value rather than a reference", () => { + const CONTRACT = { + declares: { ASSET_AMOUNT: "u64", ASSET_ID: "u256", WITH_ASSET_BURN: "bool" }, + source: "./asset_auth.simf", + }; + + test("is encoded from the type its contract declares for it", () => { + const result = resolveCompileParams( + { ASSET_AMOUNT: "1", WITH_ASSET_BURN: "false" }, + {}, + scope({}), + undefined, + CONTRACT, + ); + + expect(result).toEqual({ + arguments: { + ASSET_AMOUNT: { type: "u64", value: "1" }, + WITH_ASSET_BURN: { type: "bool", value: "false" }, + }, + ok: true, + }); + }); + + /** + * The order that keeps every document already working. A name means a deployment field + * wherever one exists, so a field really called `false` is still read as the field. + */ + test("but a name that resolves is still read as the name", () => { + const result = resolveCompileParams( + { ASSET_AMOUNT: "COUNT" }, + { COUNT: "u64" }, + scope({}, { COUNT: "7" }), + undefined, + CONTRACT, + ); + + expect(result).toMatchObject({ arguments: { ASSET_AMOUNT: { value: "7" } } }); + }); + + test("and a name resolving to nothing is reported as the lookup it was, not as a value", () => { + const result = resolveCompileParams( + { ASSET_AMOUNT: "MISSING" }, + {}, + scope({}), + undefined, + CONTRACT, + ); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("neither a parameter nor an argument"); + }); + + // Without the contract there is no type, and a value's own shape is never one. This is the + // state every caller was in before, and it still refuses rather than reading `1` as a number. + test("and refuses entirely when no contract says what the parameter is", () => { + const result = resolveCompileParams({ ASSET_AMOUNT: "1" }, {}, scope({})); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("ASSET_AMOUNT"); + }); + + test("and refuses a thirty-two byte value, whose byte order its width does not decide", () => { + const result = resolveCompileParams( + { ASSET_ID: `0x${"ab".repeat(32)}` }, + {}, + scope({}), + undefined, + CONTRACT, + ); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("byte order"); + }); +}); diff --git a/packages/tx-manifest/src/covenants/compileParams.ts b/packages/tx-manifest/src/covenants/compileParams.ts index f623288..33b6e77 100644 --- a/packages/tx-manifest/src/covenants/compileParams.ts +++ b/packages/tx-manifest/src/covenants/compileParams.ts @@ -1,5 +1,7 @@ import type { NormalisationNote } from "../document/normalise"; -import { type ReferenceScope, resolveReference } from "../document/references"; +import { parseReference, type ReferenceScope, resolveReference } from "../document/references"; +import { type DeclaringContract, encodeContractLiteral } from "./contractParamTypes"; +import { encodeCompileParam, encodesDeclaredType, unencodableReason } from "./paramEncoding"; /** * A contract's compile-time parameters, in SimplicityHL's own argument JSON shape. @@ -13,49 +15,6 @@ export type ResolveCompileParamsResult = | { arguments: ContractArguments; ok: true } | { ok: false; reason: string }; -/** - * The manifest's declared parameter types, mapped to the compiler's. - * - * Deliberately a closed list: a type nobody has mapped is refused rather than passed - * through, because a wrong type here produces a valid-looking wrong address rather than - * an error. The corpus's remaining types — the integer widths, `bytes32`, - * `liquid.asset_id` and `address` — arrive with the slices that need them. - */ -const PARAM_TYPES: Record<string, string> = { - // The deployed lending contracts take these: `asset_auth` a burn flag, `asset_auth_vault` - // three of them. A manifest wiring a value into one is refused without an encoding for it, - // so the corpus's own contracts are what say this is needed. - bool: "bool", - // A covenant script hash is thirty-two bytes. `u256` rather than one of the compiler's - // aliases because they are the same type: `Pubkey`, `Message`, `Scalar`, `Fe`, - // `ExplicitAsset` and `ExplicitNonce` all resolve to `U256` in simplicityhl 0.6.0 - // (`src/types.rs` L863-865), so the encoded value does not depend on which name a - // contract happens to use for it. - bytes32: "u256", - pubkey: "Pubkey", -}; - -/** - * How many bytes a declared type occupies, for the types that have a fixed width. - * - * A value of the wrong width does not fail here without this — it is hex-prefixed and - * handed to the compiler, which rejects it somewhere inside its own parser with a - * message about the parse position. That is a true error about the wrong thing: the - * fault is in the request, not the contract, and a person reading "expected end of - * input at line 1 column 143" has to work backwards to find out that they pasted an - * address where a key belongs. - */ -const PARAM_BYTES: Record<string, number> = { - bytes32: 32, - pubkey: 32, -}; - -/** What a type of fixed width should look like, for a refusal that can be acted on. */ -const SHAPES: Record<string, string> = { - bytes32: "32 bytes as 64 hexadecimal characters", - pubkey: "an x-only public key: 32 bytes as 64 hexadecimal characters, no prefix and no address", -}; - /** * Resolves the compile-time parameters a contract is built with, from the manifest's * wiring and what the request and the deployment supply. @@ -66,12 +25,26 @@ const SHAPES: Record<string, string> = { * references. This map is read as wiring; a reference inside it is resolved at the * compile-parameter site, which is what decides that `instance.`, `params.`, `args.` and a * bare name are meaningful here and the fee is not. + * + * What each value is encoded as comes from the type its parameter was declared with and from + * nothing else — never from the value's own shape. `paramEncoding` holds the closed list of + * types that have an encoding and refuses the rest by name, because the compiler accepts + * almost anything shaped like a value and returns a valid address for the wrong contract. + * + * `declaredAtUse` is the third place a type can come from, and the only one the document + * states outright. At one position — a `tapleaf` field of the deployment an action creates — + * the wiring is written `{"IS_ACTIVE": {"type": "bool", "value": "false"}}`, so the type sits + * beside the value rather than on a parameter declared elsewhere or inside the contract. Where + * it is given it wins, because a declaration at the point of use cannot be a different + * parameter's by accident, which is the one thing the other two cannot promise. */ export function resolveCompileParams( wiring: Record<string, unknown>, declaredTypes: Record<string, string>, scope: ReferenceScope, notes?: NormalisationNote[], + contract?: DeclaringContract, + declaredAtUse?: Record<string, string>, ): ResolveCompileParamsResult { const resolved: ContractArguments = {}; @@ -80,10 +53,20 @@ export function resolveCompileParams( return { ok: false, reason: `Compile parameter ${name} is not a reference.` }; } - const found = resolveReference(reference, "compileParam", scope, notes); + const found = resolveCovenantReference(reference, scope, notes); if (!found.ok) { - return { ok: false, reason: `Compile parameter ${name}: ${found.reason}` }; + const literal = + asStatedValue(name, reference, found.reason, declaredAtUse?.[name]) ?? + asContractLiteral(name, reference, found.reason, contract); + + if (!literal.ok) { + return { ok: false, reason: `Compile parameter ${name}: ${literal.reason}` }; + } + + resolved[name] = literal.encoded; + + continue; } if (typeof found.value !== "string") { @@ -96,90 +79,150 @@ export function resolveCompileParams( // A compile parameter's type comes from the parameter the manifest declares, so a // reference to something with no declared type has nothing to encode against. const declaredType = declaredTypeOf(reference, declaredTypes); - const compilerType = declaredType ? PARAM_TYPES[declaredType] : undefined; - if (!compilerType) { - return { - ok: false, - reason: `${reference} is declared as ${declaredType ?? "an unstated type"}, which this runtime does not encode yet.`, - }; + if (!encodesDeclaredType(declaredType)) { + return { ok: false, reason: `${reference} ${unencodableReason(declaredType)}.` }; } - const width = PARAM_BYTES[declaredType ?? ""]; + const encoded = encodeCompileParam(declaredType ?? "", found.value, name, reference); - if (width !== undefined) { - const malformed = wrongWidth(found.value, width, declaredType ?? "", name, reference); - - if (malformed) { - return { ok: false, reason: malformed }; - } + if (!encoded.ok) { + return encoded; } - // A boolean is written as itself rather than as bytes: the compiler reads `true` and - // `false`, and a hex-prefixed one is not an expression of that type. - resolved[name] = - compilerType === "bool" - ? { type: compilerType, value: booleanLiteral(found.value) } - : { type: compilerType, value: withHexPrefix(found.value) }; + resolved[name] = encoded.encoded; } return { arguments: resolved, ok: true }; } /** - * The declared type of whatever a reference points at. + * One compile-parameter reference, resolved against everything that can supply it. + * + * A bare name is tried as the request's own first, which is the order every other site reads + * one in. What is added here is the third place a covenant's parameter can come from: the + * fields of the deployment it belongs to. * - * Only the action's own parameters carry declared types today. An instance field or an - * argument has none, which is why a reference to one is refused here rather than encoded - * on a guess — encoding a value at the wrong width changes the address silently. + * The corpus writes it that way throughout — `{"ASSET_B": "ASSET_B"}` on a swap's offer + * covenant, `{"ISSUING_UTXOS_COUNT": "ISSUING_UTXOS_COUNT"}` on a lending protocol's factory — + * and both name fields of the deployment rather than parameters of the action being run. A + * protocol's constructor supplies those as parameters and every later action reads them back + * off the deployment, so a runtime reading only the request compiles a protocol's first action + * and refuses every one after it. + * + * Falling through here encodes nothing on a guess. A field reached this way still has to have + * been declared with a type before anything is built out of it. */ -function declaredTypeOf( +function resolveCovenantReference( reference: string, - declaredTypes: Record<string, string>, -): string | undefined { - const name = /^\$?(?:params\.)?(?<name>[A-Za-z_][A-Za-z0-9_]*)$/.exec(reference)?.groups?.name; + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: unknown } { + const found = resolveReference(reference, "compileParam", scope, notes); + const parsed = parseReference(reference); - return name === undefined ? undefined : declaredTypes[name]; + if (found.ok || parsed?.form !== "bare") { + return found; + } + + return scope.instance && parsed.name in scope.instance + ? { ok: true, value: scope.instance[parsed.name] } + : found; } /** - * A boolean as the compiler writes it. + * One wiring entry read as the value it is, at the type the document declared beside it. * - * Anything other than the two words it reads is passed through unchanged, so a manifest - * carrying something else is refused by the compiler naming the type rather than being - * quietly turned into `false` — which is a different covenant. + * Nothing is returned where the document declared no type there, so the caller falls through to + * what the contract says. The two are not alternatives to choose between by preference: this one + * is a statement in the document being read, and the contract's is a fact about a different + * artifact that happens to line up. + * + * A text shaped like a name that will not encode reports the lookup's own failure, for the same + * reason `asContractLiteral` does: text shaped like a name is nearly always meant as one, and + * "that is not 32 bytes of hex" would explain the wrong mistake to whoever reads it. */ -function booleanLiteral(value: string): string { - return value === "1" ? "true" : value === "0" ? "false" : value; -} +function asStatedValue( + name: string, + text: string, + referenceReason: string, + declaredType: string | undefined, +): EncodeLiteralResult | undefined { + if (declaredType === undefined) { + return undefined; + } + + if (!encodesDeclaredType(declaredType)) { + return { ok: false, reason: `${name} is declared ${unencodableReason(declaredType)}.` }; + } + + const encoded = encodeCompileParam(declaredType, text, name, "a value"); -function withHexPrefix(value: string): string { - return value.startsWith("0x") ? value : `0x${value}`; + if (encoded.ok) { + return encoded; + } + + return { + ok: false, + reason: parseReference(text) === undefined ? encoded.reason : referenceReason, + }; } /** - * Whether a value can be what its declared type says, by shape alone. + * One wiring entry that resolved to nothing, read as the value it is instead. + * + * Some compile parameters are wired to a bare value rather than to a name, and a value is not + * a reference — resolving one always fails. The failure is the same one a misspelled field + * produces, so the two are told apart by what the contract says rather than by how the text + * looks: a parameter the contract declares can take a value, and a parameter it does not + * declare is a lookup that failed. + * + * **A reference is tried first and keeps winning.** A deployment field is what a name means + * wherever one exists, so nothing that resolves today is re-read as a value. * - * It says which compile parameter, which reference, what arrived and what was needed, - * because all four are things the person filling the request can act on and none of - * them survives into the compiler's own message. + * Where text that is shaped like a name cannot be encoded as a value either, the lookup's own + * failure is reported. It is the one a reader can act on: text shaped like a name is nearly + * always meant as one, and saying that it is also not a valid value would explain the wrong + * mistake. */ -function wrongWidth( - value: string, - bytes: number, - declaredType: string, +function asContractLiteral( name: string, - reference: string, -): string | undefined { - const digits = value.startsWith("0x") ? value.slice(2) : value; + text: string, + referenceReason: string, + contract: DeclaringContract | undefined, +): EncodeLiteralResult { + if (!contract || contract.declares[name] === undefined) { + return { ok: false, reason: referenceReason }; + } - if (digits.length === bytes * 2 && /^[0-9a-fA-F]+$/.test(digits)) { - return undefined; + const encoded = encodeContractLiteral(name, text, contract); + + if (encoded.ok) { + return encoded; } - const found = /^[0-9a-fA-F]*$/.test(digits) - ? `${digits.length} hexadecimal characters` - : `"${value.length > 24 ? `${value.slice(0, 24)}…` : value}"`; + return { + ok: false, + reason: parseReference(text) === undefined ? encoded.reason : referenceReason, + }; +} + +type EncodeLiteralResult = + | { encoded: { type: string; value: string }; ok: true } + | { ok: false; reason: string }; - return `${name} is wired to ${reference}, declared ${declaredType}, which is ${SHAPES[declaredType] ?? `${bytes} bytes`}. Got ${found}.`; +/** + * The declared type of whatever a reference points at. + * + * The corpus writes two spellings at this site and no others — a bare name and a `params.` + * one — so those are read and anything else has no declared type here rather than a guessed + * one. Encoding a value at the wrong width changes the address silently. + */ +function declaredTypeOf( + reference: string, + declaredTypes: Record<string, string>, +): string | undefined { + const name = /^\$?(?:params\.)?(?<name>[A-Za-z_][A-Za-z0-9_]*)$/.exec(reference)?.groups?.name; + + return name === undefined ? undefined : declaredTypes[name]; } diff --git a/packages/tx-manifest/src/covenants/completeInstance.test.ts b/packages/tx-manifest/src/covenants/completeInstance.test.ts new file mode 100644 index 0000000..3f7ccad --- /dev/null +++ b/packages/tx-manifest/src/covenants/completeInstance.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test"; + +import { normaliseManifest } from "../document/normalise"; +import { completeSuppliedInstance } from "./instance"; + +/** + * Reading a deployment somebody else created. + * + * Half a deployment's fields are covenant script hashes, which are compiler output. A site that + * did not create the deployment holds the ordinary values and cannot make the rest — so an action + * reading one would refuse for want of a value only a wallet can produce. The document already + * says how each is computed, in the constructor's own block. + */ + +const DOCUMENT = { + classes: { + Vault: { + methods: { + Open: { + create_instance: { + fields: { + ASSET: "$params.ASSET", + COV_HASH: { + params: { ASSET_ID: { type: "liquid.asset_id", value: "ASSET" } }, + simf: "./v.simf", + type: "tapleaf", + }, + }, + }, + }, + Spend: { inputs: [{ id: "a", utxo_source: { utxo_type: "v" } }] }, + }, + }, + }, + utxo_types: { v: { script: { compile_params: { ASSET_ID: "ASSET" }, source: "./v.simf" } } }, +}; + +const { manifest } = normaliseManifest(DOCUMENT as unknown as Record<string, unknown>); +const spend = manifest.actions.find((action) => action.name === "Spend")!; +const open = manifest.actions.find((action) => action.name === "Open")!; +const ASSET = "aa".repeat(32); + +function complete(action: typeof spend, supplied: Record<string, unknown>) { + return completeSuppliedInstance(manifest, action, supplied, { + contractSources: { "./v.simf": "fn main() {}" }, + hashCovenant: () => "cc".repeat(32), + }); +} + +describe("a deployment read rather than created", () => { + test("gains the fields only a compiler could produce", () => { + const result = complete(spend, { ASSET }); + + expect(result.ok && result.fields).toEqual({ ASSET, COV_HASH: "cc".repeat(32) }); + }); + + test("keeps what the site supplied, because that is what the deployment was recorded with", () => { + const result = complete(spend, { ASSET, COV_HASH: "dd".repeat(32) }); + + expect(result.ok && result.fields.COV_HASH).toBe("dd".repeat(32)); + }); + + test("leaves the constructor's own reading alone", () => { + const result = complete(open, { ASSET }); + + expect(result.ok && result.fields).toEqual({ ASSET }); + }); +}); + +/** + * A deployment whose constructor reads a parameter the document states a default for. + * + * `ZERO_HASH` in the lending document is the shape: a constant the deployment was recorded + * with, declared once in the constructor's parameters and never sent by anybody. A site + * reading that deployment holds the values the indexer publishes and has no reason to carry + * the document's own constants — asking it for one asks it to send back what the document + * already says. + */ +const WITH_DEFAULT = { + classes: { + Vault: { + methods: { + Open: { + create_instance: { + fields: { + ASSET: "$params.ASSET", + COV_HASH: { + params: { + ASSET_ID: { type: "liquid.asset_id", value: "ASSET" }, + ZERO: { type: "bytes32", value: "ZERO_HASH" }, + }, + simf: "./v.simf", + type: "tapleaf", + }, + ZERO_HASH: "$params.ZERO_HASH", + }, + }, + params: { + ASSET: { type: "liquid.asset_id" }, + ZERO_HASH: { default: "00".repeat(32), type: "bytes32" }, + }, + }, + Spend: { inputs: [{ id: "a", utxo_source: { utxo_type: "v" } }] }, + }, + }, + }, + utxo_types: { v: { script: { compile_params: { ASSET_ID: "ASSET" }, source: "./v.simf" } } }, +}; + +const withDefault = normaliseManifest(WITH_DEFAULT as unknown as Record<string, unknown>).manifest; + +function completeWithDefaults(name: string, supplied: Record<string, unknown>) { + return completeSuppliedInstance( + withDefault, + withDefault.actions.find((action) => action.name === name)!, + supplied, + { contractSources: { "./v.simf": "fn main() {}" }, hashCovenant: () => "cc".repeat(32) }, + ); +} + +describe("a constant the document states rather than the site", () => { + test("is read from the constructor's own default", () => { + const result = completeWithDefaults("Spend", { ASSET }); + + expect(result.ok && result.fields).toEqual({ + ASSET, + COV_HASH: "cc".repeat(32), + ZERO_HASH: "00".repeat(32), + }); + }); + + test("still loses to what the site supplied", () => { + const result = completeWithDefaults("Spend", { ASSET, ZERO_HASH: "11".repeat(32) }); + + expect(result.ok && result.fields.ZERO_HASH).toBe("11".repeat(32)); + }); +}); + +/** + * A parameter the document computes can name a field of the deployment that only the wallet + * can produce — the lending document's repayment splits are computed from `CURRENT_DEBT`, + * which its constructor works out and no site holds. Filling parameters before completing the + * deployment refuses on a field the wallet was one step away from deriving. + */ +const COMPUTES_FROM_A_DERIVED_FIELD = { + classes: { + Vault: { + methods: { + Open: { + create_instance: { + fields: { AMOUNT: "$params.AMOUNT", DEBT: "params.AMOUNT + 50" }, + }, + params: { AMOUNT: { type: "u64" } }, + }, + Settle: { + inputs: [{ id: "a", utxo_source: { utxo_type: "v" } }], + params: { SHARE: { compute: "instance.DEBT - 10", type: "u64" } }, + }, + }, + }, + }, + utxo_types: { v: { script: { compile_params: {}, source: "./v.simf" } } }, +}; + +describe("a parameter computed from a field the constructor worked out", () => { + test("is answered once the deployment is completed", () => { + const { manifest: document } = normaliseManifest( + COMPUTES_FROM_A_DERIVED_FIELD as unknown as Record<string, unknown>, + ); + const settle = document.actions.find((action) => action.name === "Settle")!; + const completed = completeSuppliedInstance( + document, + settle, + { AMOUNT: "1000" }, + { + contractSources: { "./v.simf": "fn main() {}" }, + hashCovenant: () => "cc".repeat(32), + }, + ); + + expect(completed.ok && completed.fields.DEBT).toBe("1050"); + }); +}); diff --git a/packages/tx-manifest/src/covenants/computed.ts b/packages/tx-manifest/src/covenants/computed.ts index 4b371da..2454966 100644 --- a/packages/tx-manifest/src/covenants/computed.ts +++ b/packages/tx-manifest/src/covenants/computed.ts @@ -21,8 +21,19 @@ export const COVENANT_HASH_SEED = "0".repeat(64); */ export const ITERATION_BOUND = 8; -/** Compiles a contract with its arguments and returns the hash of its scriptPubKey. */ -export type HashCovenant = (input: { argumentsJson: string; source: string }) => string; +/** + * Compiles a contract with its arguments and returns the hash of its scriptPubKey. + * + * The leaves travel beside the arguments because they are part of the tree the scriptPubKey is + * derived from, and a hash of a covenant built without them is the hash of a different + * covenant. A tapleaf declaring none passes an empty list, which is what a full derivation + * already passes in the same case. + */ +export type HashCovenant = (input: { + argumentsJson: string; + extraLeavesJson: string; + source: string; +}) => string; export type ComputedParamsResult = | { ok: false; reason: string } @@ -87,8 +98,11 @@ export function resolveComputedParams( return { ok: false, reason: `Computing ${name}: ${resolved.reason}` }; } + // No leaves: a computed parameter carrying `extra_leaves` is refused above, so an + // empty list is the whole truth here rather than a value stood in for one. next[name] = input.hashCovenant({ argumentsJson: JSON.stringify(resolved.arguments), + extraLeavesJson: "[]", source, }); } diff --git a/packages/tx-manifest/src/covenants/contractParamTypes.test.ts b/packages/tx-manifest/src/covenants/contractParamTypes.test.ts new file mode 100644 index 0000000..59b6168 --- /dev/null +++ b/packages/tx-manifest/src/covenants/contractParamTypes.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeContractLiteral } from "./contractParamTypes"; + +/** + * The types below are the compiler's own answer for the vendored contracts, read from + * `contractParameterTypes` and pinned here so this file can be exercised without a compiler. + * The adapter's own test proves the compiler still says this. + */ +const ASSET_AUTH = { + declares: { ASSET_AMOUNT: "u64", ASSET_ID: "u256", WITH_ASSET_BURN: "bool" }, + source: "./asset_auth.simf", +}; + +describe("a value encoded from the type its contract declares", () => { + test("a count is written as decimal at the width the contract declared", () => { + expect(encodeContractLiteral("ASSET_AMOUNT", "1", ASSET_AUTH)).toEqual({ + encoded: { type: "u64", value: "1" }, + ok: true, + }); + }); + + test("a flag is written as the word the compiler reads", () => { + expect(encodeContractLiteral("WITH_ASSET_BURN", "false", ASSET_AUTH)).toEqual({ + encoded: { type: "bool", value: "false" }, + ok: true, + }); + + expect(encodeContractLiteral("WITH_ASSET_BURN", "true", ASSET_AUTH)).toEqual({ + encoded: { type: "bool", value: "true" }, + ok: true, + }); + }); + + /** + * The width is the contract's, not the value's. `1` fits every integer type there is, so a + * runtime reading the value would have no reason to pick one — and every wrong pick is a + * different contract. + */ + test("the width comes from the contract even when the value would fit anything", () => { + const narrower = { declares: { ASSET_AMOUNT: "u8" }, source: "./elsewhere.simf" }; + + expect(encodeContractLiteral("ASSET_AMOUNT", "1", narrower)).toEqual({ + encoded: { type: "u8", value: "1" }, + ok: true, + }); + }); +}); + +describe("what it refuses rather than guessing", () => { + test("a value too large for the width the contract declared", () => { + const narrower = { declares: { ASSET_AMOUNT: "u8" }, source: "./asset_auth.simf" }; + const result = encodeContractLiteral("ASSET_AMOUNT", "256", narrower); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("0 to 255"); + }); + + test("a flag written as anything but the two words it has", () => { + const result = encodeContractLiteral("WITH_ASSET_BURN", "yes", ASSET_AUTH); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("true or false"); + }); + + /** + * The refusal this issue exists to keep. A contract declares an asset id and a covenant hash + * with the same type, and they are written in opposite byte orders — so `u256` does not say + * which of the two a bare value is. A name does, because the format declares its type, which + * is why every thirty-two byte parameter in the corpus is wired to one. + */ + test("a thirty-two byte value, whose byte order its width does not decide", () => { + const result = encodeContractLiteral("ASSET_ID", `0x${"ab".repeat(32)}`, ASSET_AUTH); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("byte order"); + expect(result.ok ? "" : result.reason).toContain("ASSET_ID"); + expect(result.ok ? "" : result.reason).toContain("./asset_auth.simf"); + }); + + test("a parameter the contract does not declare, naming the contract that does not", () => { + const result = encodeContractLiteral("NOT_A_PARAMETER", "1", ASSET_AUTH); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("NOT_A_PARAMETER"); + expect(result.ok ? "" : result.reason).toContain("./asset_auth.simf"); + }); + + test("a type the contract really does declare that nobody has mapped", () => { + const other = { declares: { COUNT: "u128" }, source: "./other.simf" }; + const result = encodeContractLiteral("COUNT", "1", other); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("u128"); + expect(result.ok ? "" : result.reason).toContain("./other.simf"); + }); +}); + +/** + * The invariant that makes a mapping mistake impossible to ship. + * + * The compiler requires an argument's type to equal its parameter's exactly. Anything typed + * differently from what the contract declared would be refused there — but with a message that + * names neither the document nor the parameter, and only after everything else had gone right. + */ +describe("what it emits is typed as the contract declared it", () => { + for (const [declaredType, value] of [ + ["bool", "true"], + ["u8", "7"], + ["u16", "7"], + ["u32", "7"], + ["u64", "7"], + ] as const) { + test(`${declaredType} is emitted as ${declaredType}`, () => { + const result = encodeContractLiteral("P", value, { + declares: { P: declaredType }, + source: "./c.simf", + }); + + expect(result.ok && result.encoded.type).toBe(declaredType); + }); + } +}); diff --git a/packages/tx-manifest/src/covenants/contractParamTypes.ts b/packages/tx-manifest/src/covenants/contractParamTypes.ts new file mode 100644 index 0000000..ab4bde1 --- /dev/null +++ b/packages/tx-manifest/src/covenants/contractParamTypes.ts @@ -0,0 +1,137 @@ +import { encodeCompileParam, type EncodeParamResult } from "./paramEncoding"; + +/** + * What a contract itself says the types of its compile parameters are. + * + * A deployment mostly wires a compile parameter to a name, and the name carries the format's + * own declared type. Some parameters are wired to a bare value instead — a number, or one of + * the two words a flag is written as. That position declares nothing, because it is a + * deployment's wiring rather than a list of parameters, so the value arrives with no type and + * there is nothing to encode it against. + * + * The type still exists. It is just not in the document. + * + * **`SimplicityHL` has no syntax for declaring a parameter's type.** `param::NAME` is written + * where a value is wanted and the type checker gives it the type that position demands — + * `simplicityhl` 0.6.0 inserts the parameter into the program's global map under the expected + * type of the expression it stands in for (`src/ast.rs` L1346-1350). So a parameter's type is + * not written down anywhere in the source; it is a result of analysing the source, and the + * only thing that can state it is the compiler. + * + * That is why this takes the types as given rather than reading the contract text. A reader + * that recovered them from the source would be reimplementing the type checker, and the + * failure mode of getting one wrong is a value encoded at a width nobody stated. + */ +export type ContractParamTypes = Record<string, string>; + +/** + * A contract, and what it declares — enough to encode a value against and to name in a refusal. + * + * The source path is carried because a refusal has to say which contract decided the answer. + * Two utxo types can wire the same parameter name into different contracts, and "the contract + * does not take a value there" is only actionable if the reader knows which one is meant. + */ +export type DeclaringContract = { + declares: ContractParamTypes; + /** The contract source path, as the document names it. */ + source: string; +}; + +/** + * The compiler's own type names, mapped to the encoding entry each one shares. + * + * The entry names on the right are the format's declared-type names, and for these five they + * are spelled identically to the compiler's. That is not relied upon quietly: `encodeContractLiteral` + * checks that what comes back is typed as the contract asked for, so a mapping that ever stopped + * lining up would refuse rather than compile something else. + * + * The list is closed for the same reason the encoding list is closed. A type nobody has mapped + * is refused by name rather than passed through. + */ +const CONTRACT_TYPES: Record<string, string> = { + bool: "bool", + u8: "u8", + u16: "u16", + u32: "u32", + u64: "u64", +}; + +/** + * Why a type a contract really does declare still cannot take a bare value. + * + * These are not gaps waiting to be filled in by pattern. Each one names something the position + * does not say, and a value written there would have to be guessed at rather than read. + */ +const UNENCODABLE: Record<string, string> = { + u256: + "a thirty-two byte value's byte order is not decided by its width. An asset id is " + + "committed in the reverse of how it is written and a covenant hash is not, and a " + + "contract declares both as u256 — so a name, which carries the format's own type, can " + + "be encoded here and a bare value cannot", +}; + +/** + * One compile parameter written as a bare value, encoded from the type its contract declares. + * + * Nothing here looks at the value. `"1"` is not read as a small number, `"false"` is not read + * as a flag, and a run of sixty-four hexadecimal characters is not read as a hash. The type + * decides, and where the contract's type does not decide the encoding on its own, this refuses. + * + * The refusal names the parameter and the contract, because those are what a person can act on: + * the document wires a value into a parameter, and the contract is what says whether a value + * belongs there at all. + */ +export function encodeContractLiteral( + name: string, + value: string, + contract: DeclaringContract, +): EncodeParamResult { + const declaredType = contract.declares[name]; + + if (declaredType === undefined) { + return { + ok: false, + reason: + `${name} is written as the value "${value}", and ${contract.source} declares no ` + + "compile parameter of that name to take it.", + }; + } + + const entry = CONTRACT_TYPES[declaredType]; + + if (entry === undefined) { + const known = UNENCODABLE[declaredType]; + + return { + ok: false, + reason: + `${name} is written as the value "${value}", and ${contract.source} declares it ` + + `${declaredType}, which this runtime does not encode from a value` + + `${known === undefined ? "" : `: ${known}`}.`, + }; + } + + const encoded = encodeCompileParam(entry, value, name, "a value"); + + if (!encoded.ok) { + return { + ok: false, + reason: `${encoded.reason} That is the type ${contract.source} declares for it.`, + }; + } + + // The compiler requires an argument's type to equal its parameter's exactly, so anything + // typed differently from what the contract declared would be refused there rather than + // built wrongly. Refusing it here says which parameter and which contract, which the + // compiler's own message does not. + if (encoded.encoded.type !== declaredType) { + return { + ok: false, + reason: + `${name} is declared ${declaredType} by ${contract.source}, and this runtime ` + + `encoded it as ${encoded.encoded.type}.`, + }; + } + + return encoded; +} diff --git a/packages/tx-manifest/src/covenants/covenant.test.ts b/packages/tx-manifest/src/covenants/covenant.test.ts index c7bc21f..9b4e8a6 100644 --- a/packages/tx-manifest/src/covenants/covenant.test.ts +++ b/packages/tx-manifest/src/covenants/covenant.test.ts @@ -157,11 +157,16 @@ describe("deriveCovenantAddress with extra leaves", () => { }).manifest; } - function derive(extraLeaves: unknown[], stateVars?: Record<string, unknown>) { + function derive( + extraLeaves: unknown[], + stateVars?: Record<string, unknown>, + instance?: Record<string, unknown>, + ) { const { calls, compile } = compiler(); + const scope = { instance, params: { pubkey: PUBKEY } }; return deriveCovenantAddress(manifestWithLeaves(extraLeaves, stateVars), { - ...request(), + ...request(instance ? { scope } : {}), compile, }).then((result) => ({ calls, result })); } @@ -191,4 +196,63 @@ describe("deriveCovenantAddress with extra leaves", () => { expect(result).toMatchObject({ ok: false }); expect(calls).toHaveLength(0); }); + + /** + * The two leaves a live lending protocol writes on its collateral covenant, in the shape it + * writes them: a kind and a list of parts. The bytes are the ones its own contract rebuilds + * and its own Rust builder writes — a flag in the last of thirty-two bytes, and a debt as + * eight big-endian bytes right-aligned in thirty-two. + */ + test("builds a leaf written as a kind and a payload, reading a name off the deployment", async () => { + const { calls, result } = await derive( + [ + { payload: [`0x${"00".repeat(31)}01`], type: "tapdata" }, + { + payload: [ + { + align: "right", + endian: "be", + pad_to: 32, + type: "u64", + value: "instance.CURRENT_DEBT", + }, + ], + type: "tapdata", + }, + ], + undefined, + { CURRENT_DEBT: "52500" }, + ); + + expect(result).toMatchObject({ ok: true }); + expect(JSON.parse(calls[0]?.extraLeavesJson ?? "[]")).toEqual([ + `${"00".repeat(31)}01`, + `${"00".repeat(30)}cd14`, + ]); + }); + + test("names the leaf and the part when a name in one resolves to nothing", async () => { + const { calls, result } = await derive( + ["0x00", { payload: [{ type: "u64", value: "instance.MISSING" }], type: "tapdata" }], + undefined, + { CURRENT_DEBT: "52500" }, + ); + + expect(result).toMatchObject({ ok: false }); + expect(result.ok ? "" : result.reason).toContain("extra leaf 2"); + expect(result.ok ? "" : result.reason).toContain("payload part 1"); + expect(result.ok ? "" : result.reason).toContain("instance.MISSING"); + expect(calls).toHaveLength(0); + }); + + /** + * A leaf is part of the tree the address is derived from, so a kind of leaf this compiler + * cannot build is refused rather than built as if it were the one it can. + */ + test("refuses a kind of leaf nothing can build", async () => { + const { result } = await derive([{ payload: ["0x00"], type: "tapscript" }]); + + expect(result).toMatchObject({ ok: false }); + expect(result.ok ? "" : result.reason).toContain("tapscript"); + }); }); diff --git a/packages/tx-manifest/src/covenants/covenant.ts b/packages/tx-manifest/src/covenants/covenant.ts index 81b0fb6..59c1450 100644 --- a/packages/tx-manifest/src/covenants/covenant.ts +++ b/packages/tx-manifest/src/covenants/covenant.ts @@ -1,8 +1,9 @@ -import { asArray, asRecord } from "../document/json"; +import { asRecord } from "../document/json"; import type { NormalisationNote, NormalisedManifest } from "../document/normalise"; import type { ReferenceScope } from "../document/references"; -import { encodeLeafItem } from "../evaluation/encode"; import { resolveCompileParams } from "./compileParams"; +import type { ContractParamTypes } from "./contractParamTypes"; +import { encodeExtraLeaves } from "./extraLeaves"; /** * Compiles a covenant and reports the address it derives. @@ -20,6 +21,20 @@ export type CompileCovenant = (input: { source: string; }) => Promise<CompiledCovenant> | CompiledCovenant; +/** + * What a contract says the types of its own compile parameters are. + * + * Supplied the same way the compile step is, and for the same reason: the answer comes from the + * compiler, and the compiler's lifecycle belongs to the wallet rather than to this package. + * + * It is separate from compiling because it is needed *before* a compile — a parameter written + * as a bare value has no type until the contract states one, and the arguments a compile takes + * cannot be built until it does. Asking a compiled contract instead would be circular. + */ +export type ContractParamTypesOf = ( + source: string, +) => Promise<ContractParamTypes> | ContractParamTypes; + /** * What one compile yields: where the covenant is, in both forms a transaction needs. * @@ -72,6 +87,8 @@ export async function deriveCovenantAddress( manifest: NormalisedManifest, input: { compile: CompileCovenant; + /** What the contract itself declares, for the parameters a deployment writes as values. */ + contractParamTypes?: ContractParamTypesOf; contractSources: Record<string, string>; declaredTypes: Record<string, string>; includeDebugSymbols: boolean; @@ -107,13 +124,39 @@ export async function deriveCovenantAddress( ...input.wiring, }; - const params = resolveCompileParams(wiring, input.declaredTypes, input.scope, input.notes); + // Read before resolving, because what the contract declares is what decides the parameters + // the document writes as values. A contract that will not analyse is reported the way one + // that will not compile is: it is the same failure, found one step earlier. + let declaring: { declares: ContractParamTypes; source: string } | undefined; + + if (input.contractParamTypes) { + try { + declaring = { declares: await input.contractParamTypes(source), source: sourcePath }; + } catch (error) { + return { + ok: false, + reason: `The contract at ${sourcePath} did not compile: ${String(error)}`, + }; + } + } + + const params = resolveCompileParams( + wiring, + input.declaredTypes, + input.scope, + input.notes, + declaring, + ); if (!params.ok) { return params; } - const leaves = encodeExtraLeaves(asRecord(declared.script), asRecord(declared.state_vars)); + const leaves = encodeExtraLeaves(asRecord(declared.script)?.extra_leaves, { + notes: input.notes, + scope: input.scope, + stateVars: asRecord(declared.state_vars) ?? {}, + }); if (!leaves.ok) { return { ok: false, reason: `Utxo type "${input.utxoType}": ${leaves.reason}` }; @@ -182,30 +225,3 @@ export function covenantMatchesChain( "This is not the contract the site described.", }; } - -/** - * The encoded payloads of a utxo type's extra taproot leaves, in declaration order. - * - * Order is part of the address, so this preserves it rather than collecting into anything - * that would not. A leaf that cannot be encoded refuses the whole derivation: a covenant - * missing one of its leaves is a different covenant, and deriving an address for it would - * produce a well-formed answer to the wrong question. - */ -function encodeExtraLeaves( - script: Record<string, unknown> | undefined, - stateVars: Record<string, unknown> | undefined, -): { hex: string[]; ok: true } | { ok: false; reason: string } { - const hex: string[] = []; - - for (const item of asArray(script?.extra_leaves)) { - const encoded = encodeLeafItem(item, stateVars ?? {}); - - if (!encoded.ok) { - return encoded; - } - - hex.push(encoded.hex); - } - - return { hex, ok: true }; -} diff --git a/packages/tx-manifest/src/covenants/covenantHash.test.ts b/packages/tx-manifest/src/covenants/covenantHash.test.ts index dd5399b..8f98089 100644 --- a/packages/tx-manifest/src/covenants/covenantHash.test.ts +++ b/packages/tx-manifest/src/covenants/covenantHash.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { covenantHashFrom } from "./covenantHash"; +import { type CompileScriptPubKey, covenantHashFrom } from "./covenantHash"; // A covenant script hash is SHA256 of the scriptPubKey the contract compiles to — the value // the Simplicity `input_script_hash` jet returns, and what a manifest's *_COV_HASH fields @@ -8,41 +8,69 @@ import { covenantHashFrom } from "./covenantHash"; // scriptPubKey itself is right is the compiler's business and is covered against the real // module elsewhere. +/** A contract with no leaves, which is what most of these cases are about. */ +const bare = { argumentsJson: "{}", extraLeavesJson: "[]", source: "a" }; +const hashWith = (compile: CompileScriptPubKey) => covenantHashFrom(compile, false); + describe("covenantHashFrom", () => { test("hashes the scriptPubKey the contract compiles to", () => { - const hash = covenantHashFrom(() => "00")({ argumentsJson: "{}", source: "fn main() { }" }); + const hash = hashWith(() => "00")({ ...bare, source: "fn main() { }" }); // SHA256 of the single byte 0x00. expect(hash).toBe("6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d"); }); test("a different scriptPubKey gives a different hash", () => { - const one = covenantHashFrom(() => "00")({ argumentsJson: "{}", source: "a" }); - const other = covenantHashFrom(() => "01")({ argumentsJson: "{}", source: "a" }); + const one = hashWith(() => "00")(bare); + const other = hashWith(() => "01")(bare); expect(one).not.toBe(other); }); test("hashes the bytes rather than the text of the hex", () => { - const lower = covenantHashFrom(() => "abcd")({ argumentsJson: "{}", source: "a" }); - const upper = covenantHashFrom(() => "ABCD")({ argumentsJson: "{}", source: "a" }); + const lower = hashWith(() => "abcd")(bare); + const upper = hashWith(() => "ABCD")(bare); expect(lower).toBe(upper); }); - test("passes the source and arguments through to the compiler unchanged", () => { - const seen: { argumentsJson: string; source: string }[] = []; + test("passes the source, arguments and leaves through to the compiler unchanged", () => { + const seen: Parameters<CompileScriptPubKey>[0][] = []; - covenantHashFrom((input) => { + hashWith((input) => { seen.push(input); return "00"; - })({ argumentsJson: '{"A":1}', source: "fn main() { }" }); + })({ argumentsJson: '{"A":1}', extraLeavesJson: '["aa"]', source: "fn main() { }" }); + + expect(seen).toEqual([ + { + argumentsJson: '{"A":1}', + extraLeavesJson: '["aa"]', + includeDebugSymbols: false, + source: "fn main() { }", + }, + ]); + }); + + // The mode is bound once for a whole document rather than passed per call, so this is the + // only place it can be got wrong — and the same parameters build a different covenant in the + // other mode, which is measured against the deployed protocol elsewhere. + test("compiles in the build mode it was bound with", () => { + const seen: boolean[] = []; + const compile: CompileScriptPubKey = ({ includeDebugSymbols }) => { + seen.push(includeDebugSymbols); + + return "00"; + }; + + covenantHashFrom(compile, true)(bare); + covenantHashFrom(compile, false)(bare); - expect(seen).toEqual([{ argumentsJson: '{"A":1}', source: "fn main() { }" }]); + expect(seen).toEqual([true, false]); }); test("refuses a scriptPubKey that is not hex rather than hashing the text", () => { - expect(() => covenantHashFrom(() => "zz")({ argumentsJson: "{}", source: "a" })).toThrow(); + expect(() => hashWith(() => "zz")(bare)).toThrow(); }); }); diff --git a/packages/tx-manifest/src/covenants/covenantHash.ts b/packages/tx-manifest/src/covenants/covenantHash.ts index 16b724d..71b194d 100644 --- a/packages/tx-manifest/src/covenants/covenantHash.ts +++ b/packages/tx-manifest/src/covenants/covenantHash.ts @@ -3,8 +3,21 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import type { HashCovenant } from "./computed"; -/** Compiles a contract with its arguments and returns the scriptPubKey it locks to, as hex. */ -export type CompileScriptPubKey = (input: { argumentsJson: string; source: string }) => string; +/** + * Compiles a contract with its arguments and returns the scriptPubKey it locks to, as hex. + * + * The same four things a full compile takes, because a hash of a covenant that was built + * differently from the covenant is a hash of nothing. The leaves and the build mode are not + * optional refinements of an address: each of them changes it outright. + */ +export type CompileScriptPubKey = (input: { + argumentsJson: string; + /** Already-encoded taproot leaf payloads, appended to the tree in declaration order. */ + extraLeavesJson: string; + /** The mode this protocol declares its contracts were built in. */ + includeDebugSymbols: boolean; + source: string; +}) => string; /** * Turns a compiler into the covenant-hash function the computed parameters need. @@ -14,7 +27,19 @@ export type CompileScriptPubKey = (input: { argumentsJson: string; source: strin * therefore a hash of the *bytes*, and the compiler hands back hex; decoding first rather * than hashing the text is the difference between the value a contract will check against * and a plausible-looking wrong one. + * + * **The build mode is bound here rather than asked for per call.** It is a property of the + * document — one protocol declares its contracts were built with debug symbols and another + * does not — so it cannot vary between two covenants of the same manifest, and a caller able + * to pass it per call is a caller able to pass it inconsistently. The same two integers build + * a different covenant in the other mode, which is measured rather than assumed. */ -export function covenantHashFrom(compile: CompileScriptPubKey): HashCovenant { - return (input) => bytesToHex(sha256(hexToBytes(compile(input)))); +export function covenantHashFrom( + compile: CompileScriptPubKey, + includeDebugSymbols: boolean, +): HashCovenant { + return ({ argumentsJson, extraLeavesJson, source }) => + bytesToHex( + sha256(hexToBytes(compile({ argumentsJson, extraLeavesJson, includeDebugSymbols, source }))), + ); } diff --git a/packages/tx-manifest/src/covenants/createdInstance.test.ts b/packages/tx-manifest/src/covenants/createdInstance.test.ts new file mode 100644 index 0000000..6d6d69d --- /dev/null +++ b/packages/tx-manifest/src/covenants/createdInstance.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; + +import lendingV3 from "../__fixtures__/current/lending_v3.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import { resolveCreatedInstance } from "./instance"; + +/** + * The deployment a constructor brings into existence, worked out from a published document. + * + * `instance.test.ts` covers the reference spellings and the fixed point. This covers the three + * things a live protocol's constructor needs that a wallet reading only references cannot do: + * a field the document computes, a covenant hash built with the leaves its own tree carries, + * and a deployment read before the transaction's own inputs have produced everything. + */ + +const { manifest } = normaliseManifest(lendingV3 as unknown as Record<string, unknown>); +const createOffer = findAction(manifest, "CreateOffer"); + +if (!createOffer) { + throw new Error("The document declares no CreateOffer"); +} + +const SOURCES = { + "./asset_auth.simf": "fn main() { asset_auth }", + "./asset_auth_vault.simf": "fn main() { vault }", + "./lending.simf": "fn main() { lending }", +}; + +/** A stand-in compiler whose answer depends on everything the real one is given. */ +function hashOf(input: { argumentsJson: string; extraLeavesJson: string; source: string }): string { + const digest = new Bun.CryptoHasher("sha256"); + + digest.update(`${input.source} ${input.argumentsJson} ${input.extraLeavesJson}`); + + return digest.digest("hex"); +} + +const asset = (byte: string) => byte.repeat(32); + +/** What the request supplies, which for this action is everything a person filled in. */ +const PARAMS: Record<string, string> = { + COLLATERAL_AMOUNT: "100000", + COLLATERAL_ASSET_ID: asset("c1"), + FACTORY_ASSET_ID: asset("f1"), + LOAN_EXPIRATION_TIME: "1900000000", + PRINCIPAL_AMOUNT: "50000", + PRINCIPAL_ASSET_ID: asset("a1"), + PRINCIPAL_INTEREST_RATE: "500", + PROTOCOL_FEE_KEEPER_ASSET_ID: asset("e1"), + ZERO_HASH: "00".repeat(32), +}; + +/** What the action's own inputs produced: two assets, neither of which existed beforehand. */ +const ISSUED: Record<string, string> = { + BORROWER_NFT_ASSET_ID: asset("b1"), + LENDER_NFT_ASSET_ID: asset("d1"), +}; + +const resolve = ( + scope: { instance?: Record<string, string>; params: Record<string, string> }, + unresolved?: "omit" | "refuse", +) => + resolveCreatedInstance(createOffer, { + contractSources: SOURCES, + hashCovenant: hashOf, + scope, + ...(unresolved === undefined ? {} : { unresolved }), + }); + +describe("a deployment a constructor creates", () => { + test("is worked out completely once the action's own inputs have produced their assets", () => { + const found = resolve({ instance: ISSUED, params: PARAMS }); + + expect(found.ok).toBe(true); + + if (!found.ok) { + return; + } + + expect(found.instance.fields.BORROWER_NFT_ASSET_ID).toBe(ISSUED.BORROWER_NFT_ASSET_ID); + expect(found.instance.fields.LENDER_NFT_ASSET_ID).toBe(ISSUED.LENDER_NFT_ASSET_ID); + expect(Object.keys(found.instance.fields)).toHaveLength(20); + }); + + // The field the protocol works out for itself rather than asking a person for. 5% of 50000 + // is 2500, and what a person would have been asked to type is a number they could get wrong. + test("carries the value the document computes rather than one the request supplied", () => { + const found = resolve({ instance: ISSUED, params: PARAMS }); + + expect(found.ok && found.instance.fields.CURRENT_DEBT).toBe("52500"); + }); + + // A hash built without the leaves is the hash of a different covenant. Nothing fails on it: + // a hidden taproot node has no script to run and no witness to check, so the wrong hash goes + // into a covenant that will simply never match the one the action creates. + test("hashes a covenant with the extra leaves its own tree carries", () => { + const found = resolve({ instance: ISSUED, params: PARAMS }); + const withLeaves = found.ok ? found.instance.fields.LENDING_COV_SCRIPT_HASH : ""; + const withoutLeaves = hashOf({ + argumentsJson: "", + extraLeavesJson: "[]", + source: SOURCES["./lending.simf"], + }); + + expect(withLeaves).not.toBe(withoutLeaves); + expect(withLeaves).toHaveLength(64); + }); + + // The leaves read the field the document computes, so the two capabilities meet inside one + // hash: change the debt and the covenant this action creates is a different covenant. + test("and those leaves read the computed field, so the debt changes the hash", () => { + const found = resolve({ instance: ISSUED, params: PARAMS }); + const other = resolve({ + instance: ISSUED, + params: { ...PARAMS, PRINCIPAL_INTEREST_RATE: "600" }, + }); + + expect(found.ok && other.ok).toBe(true); + expect(found.ok && found.instance.fields.LENDING_COV_SCRIPT_HASH).not.toBe( + other.ok && other.instance.fields.LENDING_COV_SCRIPT_HASH, + ); + }); + + // The type is written beside the value at this position, and it is the only thing that says + // what width "1" is or that "false" is a flag rather than a five-letter string. + test("compiles a tapleaf parameter written as a value, at the type declared beside it", () => { + const found = resolve({ instance: ISSUED, params: PARAMS }); + + expect(found.ok).toBe(true); + expect(found.ok ? found.instance.rounds : 0).toBeGreaterThan(0); + }); +}); + +describe("the earlier moment, before the inputs have produced anything", () => { + // Which asset an issuing input carries is itself one of these fields, so nothing could be + // issued if every field had to wait for the issuance. + test("answers with the fields the request already determines", () => { + const found = resolve({ params: PARAMS }, "omit"); + + expect(found.ok).toBe(true); + expect(found.ok && found.instance.fields.COLLATERAL_ASSET_ID).toBe(PARAMS.COLLATERAL_ASSET_ID); + expect(found.ok && found.instance.fields.PRINCIPAL_AMOUNT).toBe("50000"); + }); + + test("leaves out what only the transaction can produce rather than refusing", () => { + const found = resolve({ params: PARAMS }, "omit"); + + expect(found.ok && "BORROWER_NFT_ASSET_ID" in found.instance.fields).toBe(false); + expect(found.ok && "LENDER_NFT_ASSET_ID" in found.instance.fields).toBe(false); + }); + + // A covenant hash worked out from fields that were not all in yet is not an incomplete + // answer, it is a wrong one — and nothing downstream could tell it from a right one. + test("works out no covenant hash at all from a partial deployment", () => { + const found = resolve({ params: PARAMS }, "omit"); + + expect(found.ok && "LENDING_COV_SCRIPT_HASH" in found.instance.fields).toBe(false); + expect(found.ok && found.instance.rounds).toBe(0); + }); + + test("and the same reading refuses the missing field when the deployment is recorded", () => { + const found = resolve({ params: PARAMS }); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("BORROWER_NFT_ASSET_ID"); + }); +}); diff --git a/packages/tx-manifest/src/covenants/declaredTypes.test.ts b/packages/tx-manifest/src/covenants/declaredTypes.test.ts new file mode 100644 index 0000000..aa87eab --- /dev/null +++ b/packages/tx-manifest/src/covenants/declaredTypes.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; + +import dexManifest from "../__fixtures__/current/dex.manifest.json"; +import lendingManifest from "../__fixtures__/current/lending_v3.manifest.json"; +import groupedDexManifest from "../__fixtures__/dex.manifest.json"; +import groupedLendingManifest from "../__fixtures__/lending_v3.manifest.json"; +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import { declaredParamTypes } from "./declaredTypes"; + +/** + * Where a covenant parameter's type is stated, read off the published documents themselves. + * + * The two positions are not two generations. One document writes both, because a protocol's + * constructor takes the values as parameters and every action after it reads them back off the + * deployment those parameters created. + */ + +function typesFor(document: unknown, action: string): Record<string, string> { + const { manifest } = normaliseManifest(document as Record<string, unknown>); + const found = findAction(manifest, action); + + if (!found) { + throw new Error(`This fixture declares no action named ${action}.`); + } + + return declaredParamTypes(manifest, found); +} + +describe("types an action declares for itself", () => { + test("are read, as they always were", () => { + expect(typesFor(p2pkManifest, "Pay")).toMatchObject({ pubkey: "pubkey" }); + }); + + // MakeOffer takes the offer's terms as parameters, and its covenant is wired to them by + // bare name — so this is where the swap's asset ids and amounts are stated. + test("including the ones a swap's constructor takes", () => { + expect(typesFor(dexManifest, "MakeOffer")).toMatchObject({ + AMOUNT_B: "u64", + ASSET_B: "liquid.asset_id", + MAKER_PUB_KEY: "pubkey", + MAX_FEE: "u64", + TIMEOUT: "u32", + }); + }); +}); + +describe("types the deployment declares", () => { + /** + * `Settle` takes no parameters at all. Every value its covenant is compiled with is a field + * of the offer that already exists, and the container declares their types once for every + * action performed against it. Reading only the action is why this one had nothing to + * encode against while the constructor beside it worked. + */ + test("are read for an action that declares none of its own", () => { + expect(typesFor(dexManifest, "Settle")).toMatchObject({ + AMOUNT_B: "u64", + ASSET_B: "liquid.asset_id", + MAKER_SPK: "bytes32", + MAX_FEE: "u64", + OFFER_ASSET_ID: "liquid.asset_id", + TIMEOUT: "u32", + }); + }); + + test("and for the lending protocol's factory, whose covenant is two integers", () => { + expect(typesFor(lendingManifest, "CreateOffer")).toMatchObject({ + ISSUING_UTXOS_COUNT: "u8", + REISSUANCE_FLAGS: "u64", + }); + }); + + test("without disturbing the action's own, which win where both declare a name", () => { + const types = typesFor(lendingManifest, "CreateFactory"); + + // The factory's container and its constructor both declare these, and agree. What the + // test pins is that the constructor's declaration is the one that survives the merge: + // a value comes from the request before it comes from the deployment, so its type has + // to be read in that order. + expect(types).toMatchObject({ ISSUING_UTXOS_COUNT: "u8", REISSUANCE_FLAGS: "u64" }); + }); +}); + +describe("a covenant hash the deployment records", () => { + // Declared outright by the container, like every other field. What makes these worth + // pinning is that they are the values the lending covenant's own address is built from. + test("is read from the container's declaration, not from the shape of its value", () => { + const types = typesFor(lendingManifest, "CreateOffer"); + + expect(types.LENDER_VAULT_COV_HASH).toBe("bytes32"); + expect(types.PRINCIPAL_OUTPUT_SCRIPT_HASH).toBe("bytes32"); + expect(types.ZERO_HASH).toBe("bytes32"); + }); +}); + +/** + * The container that holds a deployment's fields was renamed with the actions inside it — + * `classes` became `contract_templates` — and the corpus keeps both generations of the same + * protocols side by side. A runtime reading only the newer name is blind to every older + * document, which is the exact failure that once hid all five of these from this wallet. + */ +describe("the generation the document was written in", () => { + test("does not decide whether its deployment's declarations are read", () => { + expect(typesFor(groupedDexManifest, "Settle")).toEqual(typesFor(dexManifest, "Settle")); + }); + + test("for the lending protocol either, whose factory covenant is two integers", () => { + expect(typesFor(groupedLendingManifest, "CreateOffer")).toMatchObject({ + ISSUING_UTXOS_COUNT: "u8", + REISSUANCE_FLAGS: "u64", + }); + }); +}); + +/** + * No published document leaves a declaration without a type, so this is the rule stated + * directly rather than found in the corpus. It is here because the failure it prevents is + * silent: a name with a made-up type is encoded and compiled and produces an address, where a + * name with no type is refused. + */ +describe("a declaration that states no type", () => { + const document = { + contract_templates: { + Thing: { + actions: { Do: { params: { SUPPLIED: { type: "u64" } } } }, + fields: { DESCRIBED_ONLY: { description: "no type here" }, TYPED: { type: "u8" } }, + }, + }, + }; + + test("gives the name no type, rather than one chosen for it", () => { + const types = typesFor(document, "Do"); + + expect(types).toEqual({ SUPPLIED: "u64", TYPED: "u8" }); + expect("DESCRIBED_ONLY" in types).toBe(false); + }); +}); + +describe("a free action", () => { + test("belongs to no deployment, so it reads only its own", () => { + const types = typesFor(p2pkManifest, "Receive"); + + expect(Object.values(types)).not.toContain("liquid.asset_id"); + }); +}); diff --git a/packages/tx-manifest/src/covenants/declaredTypes.ts b/packages/tx-manifest/src/covenants/declaredTypes.ts new file mode 100644 index 0000000..5f5c558 --- /dev/null +++ b/packages/tx-manifest/src/covenants/declaredTypes.ts @@ -0,0 +1,61 @@ +import { asRecord } from "../document/json"; +import { + declaredFields, + type NormalisedAction, + type NormalisedManifest, +} from "../document/normalise"; + +/** + * The declared types a covenant's compile parameters are encoded against. + * + * Two positions state them and the corpus uses both. An action declares the parameters a + * request supplies — `MakeOffer` says `ASSET_B` is an asset id — and the contract's own + * container declares the fields a deployment of it holds, which is where the same protocol's + * `Settle` states the same thing. They are not two generations: one document writes both, for + * the two halves of the same protocol, because a value supplied when an offer is made is a + * value read back when it is filled. + * + * Reading only the first is why every covenant a live deployment names went unencodable while + * the constructor beside it compiled. It also cannot be fixed by reading only the second: a + * constructor has no deployment to read yet. + * + * **The action wins where both declare a name.** A value comes from the request before it + * comes from the deployment, so the type has to be read in that order or a request would be + * encoded against a declaration it did not come from. + */ +export function declaredParamTypes( + manifest: NormalisedManifest, + action: NormalisedAction, +): Record<string, string> { + return { + ...typesOf(declaredFields(manifest, action)), + ...typesOf(asRecord(action.node.params) ?? {}), + }; +} + +/** + * The types one map of declarations states. + * + * Read rather than inferred, always. A value's own shape is not evidence of what it was + * declared as, and a runtime that guessed from it would read a covenant hash of sixty-four + * zeros as a number — which is a documented hazard of the format's reference implementation + * rather than a hypothetical one, and one of the corpus's own documents carries a note warning + * against exactly it. + * + * A declaration this cannot read leaves the name with no type, which refuses. That is the + * direction to fail in: the alternative is a value encoded at a width nobody stated, and a + * width is part of the address. + */ +function typesOf(declared: Record<string, unknown>): Record<string, string> { + const types: Record<string, string> = {}; + + for (const [name, entry] of Object.entries(declared)) { + const type = asRecord(entry)?.type; + + if (typeof type === "string") { + types[name] = type; + } + } + + return types; +} diff --git a/packages/tx-manifest/src/covenants/deployedCovenant.test.ts b/packages/tx-manifest/src/covenants/deployedCovenant.test.ts new file mode 100644 index 0000000..e4930b0 --- /dev/null +++ b/packages/tx-manifest/src/covenants/deployedCovenant.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import manifestJson from "../__fixtures__/current/lending_v3.manifest.json"; +import groupedManifestJson from "../__fixtures__/lending_v3.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import { buildMode } from "../document/refuse"; +import { deriveCovenantAddress } from "./covenant"; +import { declaredParamTypes } from "./declaredTypes"; + +/** + * One covenant of a live protocol, built from its own published document. + * + * `issuance_factory` is the one covenant in the corpus whose deployed scriptPubKey is written + * down: the document states that for the deployed `(2, 0)` it is the fixed + * `5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b`, and says that value + * was reproduced from the contract source. It is also the covenant whose whole address is two + * integers, which is exactly what nothing here could encode. + * + * **What this file proves and what it does not.** It proves that this runtime, reading the + * published document, asks the compiler for precisely those two arguments — the string below, + * character for character. That the compiler turns that string into the deployed script is + * proved separately and against the real wasm module, in + * `adapters/smplx/deployedCovenant.test.ts`, because this package deliberately has no compiler + * of its own: a wallet supplies one. The two halves share the argument string, so a change to + * either end breaks one of them. + * + * The transaction around this covenant is not built here and cannot be yet — both actions that + * name it deploy a new instance, and a deployment's fields are worked out from issuances that + * do not exist until the transaction does. That is a different gap and it is somebody else's. + */ + +/** + * What the compiler is asked for, once the document has been read. + * + * Not a hexadecimal digit anywhere. Both values are written as decimal, because + * `0x2` is not a `u8` the compiler will parse and `0x0000000000000002` is the same number by + * luck rather than by rule — for a larger value the two spellings are different numbers and + * both compile. + */ +const ARGUMENTS = + '{"ISSUING_UTXOS_COUNT":{"type":"u8","value":"2"},"REISSUANCE_FLAGS":{"type":"u64","value":"0"}}'; + +/** The deployed factory's parameters, as the document's own defaults state them. */ +const DEPLOYED = { ISSUING_UTXOS_COUNT: "2", REISSUANCE_FLAGS: "0" }; + +const SOURCE_PATH = "./issuance_factory.simf"; +const SOURCE = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "../__fixtures__/contracts/issuance_factory.simf"), + "utf8", +); + +const { manifest } = normaliseManifest(manifestJson as unknown as Record<string, unknown>); + +/** The same protocol as its authors published it before the container was renamed. */ +const grouped = normaliseManifest( + groupedManifestJson as unknown as Record<string, unknown>, +).manifest; + +/** Records what it was asked to compile. The real compiler is driven in the adapter's own test. */ +function recorder() { + const calls: { argumentsJson: string; includeDebugSymbols: boolean; source: string }[] = []; + + return { + calls, + compile: (input: { argumentsJson: string; includeDebugSymbols: boolean; source: string }) => { + calls.push(input); + + return { address: "ex1p_recorded", scriptPubKeyHex: `5120${"00".repeat(32)}` }; + }, + }; +} + +async function derive( + action: string, + scope: { instance: Record<string, string>; params: Record<string, string> }, + document = manifest, +) { + const found = findAction(document, action); + + if (!found) { + throw new Error(`This fixture declares no action named ${action}.`); + } + + const { calls, compile } = recorder(); + const result = await deriveCovenantAddress(document, { + compile, + contractSources: { [SOURCE_PATH]: SOURCE }, + declaredTypes: declaredParamTypes(document, found), + includeDebugSymbols: buildMode(document), + network: "liquid", + scope, + utxoType: "issuance_factory", + wiring: {}, + }); + + return { calls, result }; +} + +describe("a live protocol's factory covenant", () => { + test("is built from parameters its constructor declares", async () => { + const { calls, result } = await derive("CreateFactory", { instance: {}, params: DEPLOYED }); + + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.argumentsJson).toBe(ARGUMENTS); + }); + + /** + * The same covenant, reached by an action that declares none of these as its own. Every + * value comes off the deployment, and until the deployment's declarations were read there + * was no type to encode against and therefore no address to compare anything to. + */ + test("and from the same parameters when a later action reads them off the deployment", async () => { + const { calls, result } = await derive("CreateOffer", { instance: DEPLOYED, params: {} }); + + expect(result.ok).toBe(true); + expect(calls[0]?.argumentsJson).toBe(ARGUMENTS); + }); + + /** + * The document says its contracts were built with debug symbols, and that changes the + * commitment root and therefore the address. Asserted here because the argument string + * alone does not reproduce the deployed script without it. + */ + test("in the mode the document says its contracts were built in", async () => { + const { calls } = await derive("CreateFactory", { instance: {}, params: DEPLOYED }); + + expect(calls[0]?.includeDebugSymbols).toBe(true); + }); + + test("and from the contract source the request supplied, not one of its own", async () => { + const { calls } = await derive("CreateFactory", { instance: {}, params: DEPLOYED }); + + expect(calls[0]?.source).toBe(SOURCE); + }); + + /** + * The same protocol, published again before its container was renamed. Both generations of + * it sit in the corpus, and the deployed factory is one covenant rather than two — so a + * runtime that read only the newer document would refuse the older one against money that + * is demonstrably there. + */ + test("and identically from the generation that spells its container the older way", async () => { + const { calls, result } = await derive( + "CreateOffer", + { instance: DEPLOYED, params: {} }, + grouped, + ); + + expect(result.ok).toBe(true); + expect(calls[0]?.argumentsJson).toBe(ARGUMENTS); + expect(calls[0]?.includeDebugSymbols).toBe(true); + }); +}); + +describe("what it refuses rather than getting wrong", () => { + test("a count too large for the width the document declared it at", async () => { + const { calls, result } = await derive("CreateFactory", { + instance: {}, + params: { ...DEPLOYED, ISSUING_UTXOS_COUNT: "256" }, + }); + + expect(result.ok).toBe(false); + expect(calls).toHaveLength(0); + expect(result.ok ? "" : result.reason).toContain("0 to 255"); + }); + + test("a value nobody supplied, rather than compiling with a default of its own", async () => { + const { calls, result } = await derive("CreateOffer", { instance: {}, params: {} }); + + expect(result.ok).toBe(false); + expect(calls).toHaveLength(0); + }); +}); diff --git a/packages/tx-manifest/src/covenants/extraLeaves.ts b/packages/tx-manifest/src/covenants/extraLeaves.ts new file mode 100644 index 0000000..17d057e --- /dev/null +++ b/packages/tx-manifest/src/covenants/extraLeaves.ts @@ -0,0 +1,61 @@ +import { asArray } from "../document/json"; +import type { NormalisationNote } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; +import { encodeLeafItem } from "../evaluation/encode"; +import type { LeafPartResolver } from "../evaluation/leafParts"; + +/** + * The encoded payloads of a covenant's extra taproot leaves, in declaration order. + * + * Order is part of the address, so this preserves it rather than collecting into anything that + * would not. A leaf that cannot be encoded refuses the whole derivation: a covenant missing one + * of its leaves is a different covenant, and deriving an address for it would produce a + * well-formed answer to the wrong question. + * + * A refusal names the leaf by its position, which is the only name a leaf has — the format gives + * them no ids — and the caller adds which covenant it belongs to. Together that is enough for a + * person to find the leaf in the document they were shown. + * + * This is also where a name inside a leaf is looked up, because this is the position that says + * what a name there may mean. A covenant's leaves are part of the address it derives, so they + * read what a compile parameter reads: this deployment's fields, the request's parameters and + * arguments, and a bare name. + * + * **One reader for two positions.** The corpus declares `extra_leaves` on a utxo type's script + * and on a `tapleaf` field of the deployment an action creates, and the two are the same + * construct: in the live lending protocol the second is the script hash of the first, so a byte + * encoded differently at one of them makes a hash that will never match the covenant it is the + * hash of. Two implementations of that would be two chances to encode it differently. + */ +export function encodeExtraLeaves( + declared: unknown, + reading: { + notes?: NormalisationNote[]; + scope: ReferenceScope; + /** The utxo type's own state variables, for a leaf that names one. */ + stateVars?: Record<string, unknown>; + }, +): { hex: string[]; ok: true } | { ok: false; reason: string } { + const resolve: LeafPartResolver = (reference) => { + const found = resolveReference(reference, "extraLeaf", reading.scope, reading.notes); + + return found.ok ? { ok: true, value: found.value } : found; + }; + + const hex: string[] = []; + let position = 0; + + for (const item of asArray(declared)) { + position += 1; + + const encoded = encodeLeafItem(item, reading.stateVars ?? {}, resolve); + + if (!encoded.ok) { + return { ok: false, reason: `extra leaf ${position}: ${encoded.reason}` }; + } + + hex.push(encoded.hex); + } + + return { hex, ok: true }; +} diff --git a/packages/tx-manifest/src/covenants/instance.ts b/packages/tx-manifest/src/covenants/instance.ts index ccbe0be..74ab82d 100644 --- a/packages/tx-manifest/src/covenants/instance.ts +++ b/packages/tx-manifest/src/covenants/instance.ts @@ -1,8 +1,11 @@ -import { asArray, asRecord } from "../document/json"; +import { asRecord } from "../document/json"; import type { NormalisationNote, NormalisedAction } from "../document/normalise"; import { type ReferenceScope, resolveReference } from "../document/references"; +import { computedValue, computesValue } from "../evaluation/computedValue"; +import { literalDefaults } from "../evaluation/parameters"; import { resolveCompileParams } from "./compileParams"; import { COVENANT_HASH_SEED, type HashCovenant, ITERATION_BOUND } from "./computed"; +import { encodeExtraLeaves } from "./extraLeaves"; /** * The deployment an action creates, once its field values are worked out. @@ -65,6 +68,15 @@ function computeKind(node: Record<string, unknown>): string | undefined { * **A literal stays a literal.** Some fields hold `"0"` or `"2"` rather than a reference, * and a manifest saying a field is two means two. Resolution is tried first and a failure * to resolve is not an error for a string that names nothing. + * + * **A deployment being created is known in two moments, and this reads whichever one it is + * asked for.** Some of its fields the request and the existing deployment already determine; + * others only the action's own inputs can produce — an asset id is a function of the output + * an issuing input spends, so the field holding it cannot exist until that output has been + * chosen. The first moment is needed anyway: which asset an issuing input carries is itself + * stated as a field of the deployment being created, so nothing could be issued if every + * field had to wait for the issuance. `unresolved: "omit"` reads that moment and leaves out + * what it cannot answer; the default refuses, and is the reading the transaction is built on. */ export function resolveCreatedInstance( action: NormalisedAction, @@ -73,6 +85,19 @@ export function resolveCreatedInstance( hashCovenant: HashCovenant; notes?: NormalisationNote[]; scope: ReferenceScope; + /** + * What a field nothing in scope can supply yet is. + * + * `"refuse"` — the default — is the deployment as it will be recorded: a field left + * unresolved there is a document asking for a value nobody has. `"omit"` is the earlier + * moment, where a missing field means "not yet" rather than "never". + * + * A computed field is skipped entirely while omitting, rather than worked out from a + * partial scope. Its value is a covenant's script hash, and one derived from fields that + * were not all in yet is not an incomplete answer — it is a wrong one, of exactly the + * shape nothing downstream can tell from a right one. + */ + unresolved?: "omit" | "refuse"; }, ): CreateInstanceResult { const block = asRecord(action.node.create_instance); @@ -87,6 +112,7 @@ export function resolveCreatedInstance( return { ok: false, reason: "The action creates an instance and declares no fields for it." }; } + const omitting = input.unresolved === "omit"; const direct: Record<string, string> = {}; const computed: ComputedField[] = []; @@ -95,6 +121,10 @@ export function resolveCreatedInstance( const resolved = resolveFieldReference(name, value, input.scope, input.notes); if (!resolved.ok) { + if (omitting) { + continue; + } + return resolved; } @@ -120,11 +150,8 @@ export function resolveCreatedInstance( }; } - if (asArray(node.extra_leaves).length > 0) { - return { - ok: false, - reason: `Field ${name} carries extra_leaves, which this runtime does not encode yet.`, - }; + if (omitting) { + continue; } const simf = node.simf; @@ -167,14 +194,33 @@ export function resolveCreatedInstance( return { ok: false, reason: `Computing ${name}: ${wiring.reason}` }; } - const resolved = resolveCompileParams(wiring.wiring, declaredTypes, scope, input.notes); + const resolved = resolveCompileParams( + wiring.wiring, + declaredTypes, + scope, + input.notes, + undefined, + wiring.declaredAtUse, + ); if (!resolved.ok) { return { ok: false, reason: `Computing ${name}: ${resolved.reason}` }; } + // The leaves are read against the same scope the wiring is, and for the same reason: + // this field is the script hash of a covenant the action goes on to create, and the + // utxo type declaring that covenant writes the very same leaves reading the very same + // fields. Hashing without them would produce a value the covenant can never match, and + // a hidden taproot node has nothing to fail on — it would simply be a different tree. + const leaves = encodeExtraLeaves(node.extra_leaves, { notes: input.notes, scope }); + + if (!leaves.ok) { + return { ok: false, reason: `Computing ${name}: ${leaves.reason}` }; + } + next[name] = input.hashCovenant({ argumentsJson: JSON.stringify(resolved.arguments), + extraLeavesJson: JSON.stringify(leaves.hex), source, }); } @@ -208,16 +254,23 @@ type ComputedField = { name: string; node: Record<string, unknown>; source: stri * "MAKER_PUB_KEY"}}` — because the declaration carries the type at the point of use rather * than from a parameter declared elsewhere. The reference is the `value`, and the `type` * beside it is what the encoder needs. + * + * So the type is carried out beside the wiring rather than dropped. Most of these values are + * names and take their type from what they name; the rest are written outright — `"1"`, + * `"true"` — and the only thing that says what width or kind those are is the word the document + * wrote next to them. Without it a live protocol's deployment cannot be worked out at all, and + * guessing from the shape of `"1"` is the failure the closed type list exists to prevent. */ function tapleafWiring( node: Record<string, unknown>, -): { ok: false; reason: string } | { ok: true; wiring: Record<string, unknown> } { +): TapleafWiring | { ok: false; reason: string } { const declared = asRecord(node.params); if (!declared) { - return { ok: true, wiring: {} }; + return { declaredAtUse: {}, ok: true, wiring: {} }; } + const declaredAtUse: Record<string, string> = {}; const wiring: Record<string, unknown> = {}; for (const [name, spec] of Object.entries(declared)) { @@ -232,18 +285,44 @@ function tapleafWiring( return { ok: false, reason: `Parameter ${name} names no value to compile with.` }; } + const type = asRecord(spec)?.type; + + if (typeof type === "string") { + declaredAtUse[name] = type; + } + wiring[name] = value; } - return { ok: true, wiring }; + return { declaredAtUse, ok: true, wiring }; } +type TapleafWiring = { + /** The type the document wrote beside each value, keyed by the contract's parameter name. */ + declaredAtUse: Record<string, string>; + ok: true; + wiring: Record<string, unknown>; +}; + /** * Reads one field written as a string. * - * The corpus writes four reference spellings here and two literals. A string that resolves - * is its value; a string that names nothing is itself, because a field holding `"2"` is a - * field holding two rather than a broken reference. + * The corpus writes four reference spellings here, two literals, and arithmetic. A string that + * resolves is its value; a string that computes one is what it comes to; a string that names + * nothing is itself, because a field holding `"2"` is a field holding two rather than a broken + * reference. + * + * **The three readings are tried in that order and the order is the whole of the rule.** + * Arithmetic is recognised by the operators it is written with rather than by whether it + * evaluates, because a literal is nearly always also legal arithmetic: read as a formula, a + * field holding thirty-two zero bytes becomes `"0"`, which is a different value everywhere it + * is encoded and is not an error anywhere. `computesValue` is what keeps a literal a literal. + * + * A formula is read at the compile-parameter position, which is the position this field sits + * at — so its terms may name the request, this deployment and a bare name, and may not name the + * fee or an input the wallet resolved. Both exclusions are the same circularity: the value + * decides a covenant's address, and the fee and the inputs are read from the transaction that + * pays to it. */ function resolveFieldReference( name: string, @@ -254,6 +333,12 @@ function resolveFieldReference( const found = resolveReference(text, "compileParam", scope, notes); if (!found.ok) { + if (computesValue(text)) { + const worked = computedValue(text, "compileParam", scope, notes); + + return worked.ok ? worked : { ok: false, reason: `Field ${name}: ${worked.reason}` }; + } + // Only a text that could not name anything falls through to being a literal. One that // named something absent is a document asking for a value nobody supplied, and saying // "the field is the string $params.X" would hide that. @@ -302,3 +387,65 @@ function fieldTypes(declared: Record<string, unknown>): Record<string, string> { return types; } + +/** + * Fills in the fields of a supplied deployment that only a compiler can produce. + * + * A deployment is written once, by the action that creates it, and read by every action after. + * Half its fields are ordinary values anyone can carry — the assets, the amounts, the rate, the + * expiration — and half are covenant script hashes, which are the output of compiling a contract. + * A site that did not create the deployment can hold the first half and cannot compute the second, + * so an action reading one would refuse for want of a value nobody but a wallet can make. + * + * The document already says how each of those is computed: the constructor's `create_instance` + * block describes them, and this runtime computes them there. This reads that same description at + * the other moment, from the fields the request did supply. + * + * What the request supplies always wins. This adds what is missing and overwrites nothing, because + * a value the site holds is what the deployment was recorded with — recomputing it would be this + * wallet deciding what the deployment says about itself. + */ +export function completeSuppliedInstance( + manifest: { actions: NormalisedAction[] }, + action: NormalisedAction, + supplied: Record<string, unknown>, + input: { + contractSources: Record<string, string>; + hashCovenant: HashCovenant; + notes?: NormalisationNote[]; + }, +): { fields: Record<string, unknown>; ok: true } | { ok: false; reason: string } { + const constructor = manifest.actions.find( + (candidate) => + candidate.boundTo === action.boundTo && + asRecord(candidate.node.create_instance) !== undefined, + ); + + // A deployment with no constructor in this document is one the site holds in full or not at + // all; there is nothing here to derive it from, and saying so beats inventing a value. + if (!constructor || action.name === constructor.name) { + return { fields: supplied, ok: true }; + } + + const flat = Object.fromEntries( + Object.entries(supplied).filter(([, value]) => typeof value === "string"), + ); + // The constructor's fields read `$params.NAME` for the values it was given and + // `$instance.NAME` for the ones it worked out. Reading a deployment, both are the same + // thing: what it was recorded with — except for a parameter the document states a default + // for, which is a constant of the document rather than of the deployment. A site reading + // somebody else's deployment holds what was recorded and not what the document says about + // itself, so the default is read here and still loses to a supplied value. + const resolved = resolveCreatedInstance(constructor, { + contractSources: input.contractSources, + hashCovenant: input.hashCovenant, + ...(input.notes === undefined ? {} : { notes: input.notes }), + scope: { instance: flat, params: { ...literalDefaults(constructor), ...flat } }, + }); + + if (!resolved.ok) { + return resolved; + } + + return { fields: { ...resolved.instance.fields, ...supplied }, ok: true }; +} diff --git a/packages/tx-manifest/src/covenants/paramEncoding.test.ts b/packages/tx-manifest/src/covenants/paramEncoding.test.ts new file mode 100644 index 0000000..163ea40 --- /dev/null +++ b/packages/tx-manifest/src/covenants/paramEncoding.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeCompileParam, encodesDeclaredType, unencodableReason } from "./paramEncoding"; + +/** + * What each declared type encodes to, and — for every one of them — what it must not encode to. + * + * The second half is the point. A wrong encoding here does not fail: it compiles, derives a + * perfectly well-formed address, and locks money at it. So each type is asserted against the + * value it produces *and* against the value a plausible mistake would produce, so that a change + * to either has to be made on purpose. + */ + +const encode = (type: string, value: string) => encodeCompileParam(type, value, "P", "params.p"); +const reasonOf = (result: ReturnType<typeof encode>) => (result.ok ? "" : result.reason); + +const ASSET = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec8ef5b4d5"; +const ASSET_COMMITTED = "d5b4f58eec1ea15734ae22b7c46064412829c0d0579f0a713d1c04ede979026f"; + +describe("the integer widths", () => { + test("are written as decimal, which is what the deployed factory's parameters are", () => { + expect(encode("u8", "2")).toEqual({ + encoded: { type: "u8", value: "2" }, + ok: true, + }); + expect(encode("u64", "0")).toEqual({ encoded: { type: "u64", value: "0" }, ok: true }); + }); + + /** + * The failure this whole file exists for. + * + * `0x1000000000000000` is a legal `u64` in the compiler — sixteen hexadecimal characters is + * exactly eight bytes — and it is the number 1152921504606846976, not 1000000000000000. Both + * compile. Both derive an address. Only one of them is the covenant the document described. + */ + test("are never hex-prefixed, because a hex-prefixed amount is a different number", () => { + const encoded = encode("u64", "1000000000000000"); + + expect(encoded).toMatchObject({ encoded: { value: "1000000000000000" } }); + expect(reasonOf(encoded)).not.toContain("0x"); + expect(encoded.ok && encoded.encoded.value.startsWith("0x")).toBe(false); + }); + + test("refuse a value larger than the width it was declared at, naming both", () => { + expect(reasonOf(encode("u8", "256"))).toContain("declared u8"); + expect(reasonOf(encode("u8", "256"))).toContain("0 to 255"); + expect(encode("u8", "255").ok).toBe(true); + expect(encode("u16", "65535").ok).toBe(true); + expect(encode("u16", "65536").ok).toBe(false); + expect(encode("u32", "4294967295").ok).toBe(true); + expect(encode("u32", "4294967296").ok).toBe(false); + expect(encode("u64", "18446744073709551615").ok).toBe(true); + expect(encode("u64", "18446744073709551616").ok).toBe(false); + }); + + test("refuse a value that is not a number at all, rather than passing it to the compiler", () => { + expect(encode("u32", "0x10").ok).toBe(false); + expect(encode("u64", "-1").ok).toBe(false); + expect(encode("u64", "1e6").ok).toBe(false); + expect(reasonOf(encode("u64", "later"))).toContain("P is wired to params.p"); + }); +}); + +describe("an asset id", () => { + /** + * A covenant reads an asset with `jet::input_amount` and compares the bits against its + * baked-in parameter, and what that jet reports is the asset as the transaction commits it — + * the reverse of the order every document, request and wallet screen states one in. + * + * Measured, not reasoned: `asset_auth.simf` compiled both ways and dry-run against a + * transaction carrying the asset. The committed order executed. The stated order failed + * inside the jet — after compiling, and after producing an address. + */ + test("is turned round, because a covenant compares against the committed order", () => { + expect(encode("liquid.asset_id", ASSET)).toEqual({ + encoded: { type: "u256", value: `0x${ASSET_COMMITTED}` }, + ok: true, + }); + }); + + test("is not the value it was stated as, which is the mistake that would cost money", () => { + const encoded = encode("liquid.asset_id", ASSET); + + expect(encoded.ok && encoded.encoded.value).not.toBe(`0x${ASSET}`); + }); + + test("turned round twice is itself, so nothing here depends on which end it started at", () => { + const once = encode("liquid.asset_id", ASSET); + const twice = encode("liquid.asset_id", once.ok ? once.encoded.value : ""); + + expect(twice.ok && twice.encoded.value).toBe(`0x${ASSET}`); + }); + + test("of the wrong length is refused by length, before anything is turned round", () => { + expect(reasonOf(encode("liquid.asset_id", "6f0279e9"))).toContain("8 hexadecimal characters"); + expect(reasonOf(encode("liquid.asset_id", "6f0279e9"))).toContain("asset id"); + }); + + /** A hash is not an id: it has no stated order to be turned round from. */ + test("is turned round where a covenant hash of the same width is not", () => { + const hash = encode("bytes32", ASSET); + + expect(hash).toMatchObject({ encoded: { type: "u256", value: `0x${ASSET}` } }); + }); +}); + +describe("what has no encoding", () => { + test("is refused by name, saying what the format has not said", () => { + expect(encodesDeclaredType("address")).toBe(false); + expect(unencodableReason("address")).toContain("rendering of a locking script"); + expect(encodesDeclaredType("bytes")).toBe(false); + expect(unencodableReason("bytes")).toContain("carries no width"); + expect(encodesDeclaredType("string")).toBe(false); + }); + + test("tells a type nobody has mapped from a name nobody has heard of", () => { + expect(unencodableReason("uint256")).toContain("does not encode yet"); + expect(unencodableReason(undefined)).toContain("unstated type"); + }); + + test("never falls back to encoding it as something else", () => { + for (const type of ["address", "bytes", "string", "uint256", "tapdata"]) { + expect(encode(type, ASSET).ok).toBe(false); + } + }); +}); + +describe("what was already encodable, unchanged", () => { + const KEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + test("a key, a hash and a flag", () => { + expect(encode("pubkey", KEY)).toEqual({ + encoded: { type: "Pubkey", value: `0x${KEY}` }, + ok: true, + }); + expect(encode("bytes32", `0x${KEY}`)).toMatchObject({ encoded: { type: "u256" } }); + expect(encode("bool", "true")).toEqual({ encoded: { type: "bool", value: "true" }, ok: true }); + expect(encode("bool", "1")).toMatchObject({ encoded: { value: "true" } }); + expect(encode("bool", "0")).toMatchObject({ encoded: { value: "false" } }); + }); + + test("and a flag that is neither is refused rather than read as one of them", () => { + expect(encode("bool", "maybe").ok).toBe(false); + expect(encode("bool", "2").ok).toBe(false); + }); +}); diff --git a/packages/tx-manifest/src/covenants/paramEncoding.ts b/packages/tx-manifest/src/covenants/paramEncoding.ts new file mode 100644 index 0000000..9602b60 --- /dev/null +++ b/packages/tx-manifest/src/covenants/paramEncoding.ts @@ -0,0 +1,273 @@ +/** + * What a manifest's declared parameter type encodes to, in the compiler's own argument shape. + * + * The list is closed, and it stays closed. A type nobody has mapped is refused by name rather + * than passed through, because the compiler will accept almost anything shaped like a value + * and hand back a perfectly valid address for the wrong contract. Nothing here is derived + * from a type's name or from what a manifest says it means; each entry below records what was + * measured against the compiler and, where the value's form is not self-evident, against a + * contract executing on a transaction that carries the value. + * + * Two facts from simplicityhl 0.6.0 decide the whole table. + * + * A value is parsed as an expression and const-analysed against the declared type + * (`src/serde.rs` `ValueMapVisitor`, `src/value.rs` L789). So `0x…` is a hexadecimal literal + * and a run of digits is a decimal one, and they are different literals rather than two + * spellings of one. + * + * A hexadecimal literal must be exactly the type's width — `src/value.rs` L629-640 rejects a + * string whose length is not `byte_width * 2`. That is what makes hex safe for the fixed-width + * types and unsafe for the integers: `0x1000000000000000` is a legal `u64` and is not the + * number `1000000000000000`. + */ + +/** How a value of a declared type is written for the compiler. */ +type Encoding = "boolean" | "decimal" | "hex" | "reversedHex"; + +type ParamType = { + /** How many bytes the value occupies, for the types that have a fixed width. */ + bytes?: number; + /** The type name the compiler parses, which is not always the name the manifest uses. */ + compiler: string; + encoding: Encoding; + /** How wide the value may be, for the types bounded by a range rather than a width. */ + max?: bigint; + /** What a value of this type should look like, for a refusal that can be acted on. */ + shape: string; +}; + +/** + * The manifest's declared parameter types, mapped to the compiler's. + * + * Every type the published corpus declares at a compile-parameter site is here. The ones it + * does not declare are absent on purpose — see `unencodableReason` for what each of them + * would need before it could be added. + */ +const PARAM_TYPES: Record<string, ParamType> = { + /** + * The deployed lending contracts take these: `asset_auth` a burn flag, `asset_auth_vault` + * three of them. A manifest wiring a value into one is refused without an encoding for it, + * so the corpus's own contracts are what say this is needed. + */ + bool: { compiler: "bool", encoding: "boolean", shape: "true or false" }, + /** + * A covenant script hash is thirty-two bytes. `u256` rather than one of the compiler's + * aliases because they are the same type: `Pubkey`, `Message`, `Scalar`, `Fe`, + * `ExplicitAsset` and `ExplicitNonce` all resolve to `U256` in simplicityhl 0.6.0 + * (`src/types.rs` L863-865), so the encoded value does not depend on which name a + * contract happens to use for it. + */ + bytes32: { + bytes: 32, + compiler: "u256", + encoding: "hex", + shape: "32 bytes as 64 hexadecimal characters", + }, + /** + * An asset id is thirty-two bytes and is **written in reverse of how it is committed**. + * + * This is the one entry in the table where passing the value through would compile, derive + * an address, and be wrong. A covenant reads an asset with `jet::input_amount` or + * `jet::output_amount` and compares the bits it gets against its baked-in parameter — every + * asset-id parameter in the corpus is used that way, in `asset_auth.simf`, + * `asset_auth_vault.simf` and `lending.simf` — and what those jets report is the asset as + * the transaction commits it, which is the reverse of the form everything states an id in. + * + * Measured rather than reasoned: `asset_auth.simf` was compiled both ways and dry-run + * against a transaction whose input and output carried the asset. The committed form + * executed; the stated form failed inside the jet. Both compiled, and both produced an + * address. + * + * Everything on this side of the wallet states an id the way a person reads it — the + * chain reader turns each one round on the way in (`chain/txOut.ts`), and an id in a + * document is compared against that form — so the turn belongs here, at the one place a + * stated id becomes committed bytes. + */ + "liquid.asset_id": { + bytes: 32, + compiler: "u256", + encoding: "reversedHex", + shape: "an asset id: 32 bytes as 64 hexadecimal characters", + }, + pubkey: { + bytes: 32, + compiler: "Pubkey", + encoding: "hex", + shape: "an x-only public key: 32 bytes as 64 hexadecimal characters, no prefix and no address", + }, + /** + * The integer widths, written as decimal. + * + * A number is hex-prefixed nowhere here, and that is the point rather than a convention. + * The compiler reads `0x…` as a hexadecimal literal of exactly the type's width, so a + * decimal amount that happens to be sixteen characters long is a legal `u64` hexadecimal + * literal standing for an entirely different number. It compiles, derives an address, and + * says nothing. Measured: `1000000000000000` and `0x1000000000000000` compiled + * `issuance_factory.simf` to two different scriptPubKeys. + * + * The bound is the type's own, so a value too large for the width it was declared at is + * refused here naming both, rather than inside the compiler's parser naming a column. + */ + u8: { compiler: "u8", encoding: "decimal", max: 255n, shape: "a whole number from 0 to 255" }, + u16: { + compiler: "u16", + encoding: "decimal", + max: 65_535n, + shape: "a whole number from 0 to 65535", + }, + u32: { + compiler: "u32", + encoding: "decimal", + max: 4_294_967_295n, + shape: "a whole number from 0 to 4294967295", + }, + u64: { + compiler: "u64", + encoding: "decimal", + max: 18_446_744_073_709_551_615n, + shape: "a whole number from 0 to 18446744073709551615", + }, +}; + +/** + * Why a declared type this runtime knows of still has no encoding. + * + * Kept apart from the table above because these are not gaps to be filled by pattern: each one + * names something the format has not said, and a refusal that says which is the difference + * between a person fixing a request and a person guessing at one. + */ +const UNENCODABLE: Record<string, string> = { + address: + "an address is a rendering of a locking script rather than a value, and neither the " + + "format nor the compiler says which of the two a contract is meant to be built with", + bytes: + "a value of this type carries no width, and the compiler needs an exact one — the same " + + "bytes at two widths are two different covenants", + string: + "the compiler has no string type, so there is nothing to encode a run of text into " + + "without choosing an encoding the format never states", +}; + +export type EncodedParam = { type: string; value: string }; + +export type EncodeParamResult = { encoded: EncodedParam; ok: true } | { ok: false; reason: string }; + +/** Whether this runtime can build a contract argument out of a value of that declared type. */ +export function encodesDeclaredType(declaredType: string | undefined): boolean { + return declaredType !== undefined && declaredType in PARAM_TYPES; +} + +/** + * Why a declared type cannot be encoded, in words the person filling the request can act on. + * + * A type the format names and this runtime has not mapped is told apart from one nobody has + * heard of, because they call for different things: the first waits on a decision about what + * it means, and the second is usually a typo in the document. + */ +export function unencodableReason(declaredType: string | undefined): string { + if (declaredType === undefined) { + return "is declared as an unstated type, which this runtime does not encode"; + } + + const known = UNENCODABLE[declaredType]; + + return known === undefined + ? `is declared as ${declaredType}, which this runtime does not encode yet` + : `is declared as ${declaredType}, which this runtime does not encode: ${known}`; +} + +/** + * One compile parameter, in the compiler's argument shape. + * + * The refusals say which compile parameter, which reference, what arrived and what was needed, + * because all four are things the person filling the request can act on and none of them + * survives into the compiler's own message. + */ +export function encodeCompileParam( + declaredType: string, + value: string, + name: string, + reference: string, +): EncodeParamResult { + const declared = PARAM_TYPES[declaredType]; + + if (!declared) { + return { ok: false, reason: `${reference} ${unencodableReason(declaredType)}.` }; + } + + const wrong = (found: string): EncodeParamResult => ({ + ok: false, + reason: + `${name} is wired to ${reference}, declared ${declaredType}, which is ` + + `${declared.shape}. Got ${found}.`, + }); + + switch (declared.encoding) { + case "boolean": { + // The compiler reads `true` and `false` and nothing else of this type. The corpus also + // writes the two as 1 and 0, which are the same two values written as a bit. + const literal = BOOLEANS[value.trim().toLowerCase()]; + + return literal === undefined + ? wrong(quoted(value)) + : { encoded: { type: declared.compiler, value: literal }, ok: true }; + } + + case "decimal": { + const digits = value.trim(); + + if (!/^\d+$/.test(digits)) { + return wrong(quoted(value)); + } + + // A leading zero is dropped rather than refused: a document writing `0005` means five, + // and the compiler reads the digits as a number either way. + const number = BigInt(digits); + + return number > (declared.max ?? 0n) + ? wrong(`${number}`) + : { encoded: { type: declared.compiler, value: number.toString(10) }, ok: true }; + } + + case "hex": + case "reversedHex": { + const digits = withoutHexPrefix(value.trim()); + const width = declared.bytes ?? 0; + + if (digits.length !== width * 2 || !/^[0-9a-fA-F]+$/.test(digits)) { + return wrong( + /^[0-9a-fA-F]*$/.test(digits) ? `${digits.length} hexadecimal characters` : quoted(value), + ); + } + + const ordered = declared.encoding === "reversedHex" ? reverseBytes(digits) : digits; + + return { encoded: { type: declared.compiler, value: `0x${ordered}` }, ok: true }; + } + } +} + +/** + * The two words the compiler reads, and the two bits the corpus writes for them. + * + * Anything else is refused rather than treated as one of them. A value quietly read as `false` + * is a different covenant, and it is one that compiles. + */ +const BOOLEANS: Record<string, string> = { + "0": "false", + "1": "true", + false: "false", + true: "true", +}; + +function withoutHexPrefix(value: string): string { + return value.startsWith("0x") || value.startsWith("0X") ? value.slice(2) : value; +} + +function reverseBytes(hex: string): string { + return (hex.match(/../g) ?? []).toReversed().join(""); +} + +function quoted(value: string): string { + return `"${value.length > 24 ? `${value.slice(0, 24)}…` : value}"`; +} diff --git a/packages/tx-manifest/src/covenants/valueWiredCovenant.test.ts b/packages/tx-manifest/src/covenants/valueWiredCovenant.test.ts new file mode 100644 index 0000000..6571c1f --- /dev/null +++ b/packages/tx-manifest/src/covenants/valueWiredCovenant.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import manifestJson from "../__fixtures__/current/lending_v3.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import { buildMode } from "../document/refuse"; +import { deriveCovenantAddress } from "./covenant"; +import { declaredParamTypes } from "./declaredTypes"; + +/** + * The covenants a live protocol wires a bare value into, built from its own published document. + * + * Three of its seven utxo types write a value where the other four write a name: a count of one, + * and the words a flag is set with. A value at that position declares nothing — the position is a + * deployment's wiring, not a list of parameters — so until the contract was asked, there was no + * type, no encoding, no address, and no action spending or paying any of the three. + * + * **What this file proves and what it does not.** It proves this runtime turns the document into + * exactly the argument strings below. That the compiler reports the types they were built from, + * and that those strings compile, is proved against the real wasm module in + * `adapters/smplx/valueWiredCovenant.test.ts` — this package holds no compiler by design. The + * pinned types and the argument strings are the join, written out at both ends. + * + * **No address here was compared against a chain.** The document publishes a deployed + * scriptPubKey for its factory and for none of these three, and the asset ids below are made up, + * so what a real deployment of them is locked by is not something this can check. What is checked + * is that the arguments are the document's own and that every one of them is typed by a + * declaration rather than by its appearance. + */ + +const here = dirname(fileURLToPath(import.meta.url)); + +function contract(name: string): string { + return readFileSync(join(here, "../__fixtures__/contracts", name), "utf8"); +} + +/** + * The compiler's answer for the two contracts, pinned so this file needs no compiler. + * + * `SimplicityHL` declares a parameter's type nowhere in its source: `param::NAME` takes the type + * of the position it is written in, worked out by the type checker. So this is not a reading of + * the contract text, and it could not be — it is what the compiler said, asserted again in the + * adapter's test against the same vendored sources. + */ +const DECLARED: Record<string, Record<string, string>> = { + "./asset_auth.simf": { ASSET_AMOUNT: "u64", ASSET_ID: "u256", WITH_ASSET_BURN: "bool" }, + "./asset_auth_vault.simf": { + FINALIZED_VAULT_COV_HASH: "u256", + IS_ACTIVE: "bool", + KEEPER_AUTH_ASSET_AMOUNT: "u64", + KEEPER_AUTH_ASSET_ID: "u256", + SUPPLIER_AUTH_ASSET_ID: "u256", + VAULT_ASSET_ID: "u256", + WITH_KEEPER_ASSET_BURN: "bool", + WITH_SUPPLIER_ASSET_BURN: "bool", + }, +}; + +const MIDDLE = "00".repeat(30); + +/** + * Asset ids invented for this test, and the form each is committed in. + * + * An id is stated one way and committed in the reverse of it, so the second column is what a + * covenant is built from. The ends differ from each other so the turn is visible rather than + * asserted. + */ +const BORROWER = `b0${MIDDLE}0b`; +const BORROWER_COMMITTED = `0x0b${MIDDLE}b0`; +const PRINCIPAL = `a0${MIDDLE}0a`; +const PRINCIPAL_COMMITTED = `0x0a${MIDDLE}a0`; +const LENDER = `c0${MIDDLE}0c`; +const LENDER_COMMITTED = `0x0c${MIDDLE}c0`; +const FEE_KEEPER = `d0${MIDDLE}0d`; +const FEE_KEEPER_COMMITTED = `0x0d${MIDDLE}d0`; + +/** A covenant hash of nothing, which the document wires by name and is not an id. */ +const ZERO_HASH = "00".repeat(32); + +/** This deployment's field values, as the actions below read them back off it. */ +const INSTANCE = { + BORROWER_NFT_ASSET_ID: BORROWER, + LENDER_NFT_ASSET_ID: LENDER, + PRINCIPAL_ASSET_ID: PRINCIPAL, + PROTOCOL_FEE_KEEPER_ASSET_ID: FEE_KEEPER, + ZERO_HASH, +}; + +const { manifest } = normaliseManifest(manifestJson as unknown as Record<string, unknown>); + +async function derive(action: string, utxoType: string, source: string) { + const found = findAction(manifest, action); + + if (!found) { + throw new Error(`This fixture declares no action named ${action}.`); + } + + const calls: { argumentsJson: string; source: string }[] = []; + const result = await deriveCovenantAddress(manifest, { + compile: (input) => { + calls.push(input); + + return { address: "ex1p_recorded", scriptPubKeyHex: `5120${"00".repeat(32)}` }; + }, + contractParamTypes: () => DECLARED[source] ?? {}, + contractSources: { [source]: contract(source.replace("./", "")) }, + declaredTypes: declaredParamTypes(manifest, found), + includeDebugSymbols: buildMode(manifest), + network: "liquid", + scope: { instance: INSTANCE, params: {} }, + utxoType, + wiring: {}, + }); + + return { calls, result }; +} + +describe("the covenant behind claiming the principal", () => { + test("is built with a count and a flag typed by its contract, not by how they look", async () => { + const { calls, result } = await derive( + "ClaimPrincipal", + "principal_asset_auth", + "./asset_auth.simf", + ); + + expect(result.ok).toBe(true); + expect(calls[0]?.argumentsJson).toBe( + `{"ASSET_ID":{"type":"u256","value":"${BORROWER_COMMITTED}"},` + + '"ASSET_AMOUNT":{"type":"u64","value":"1"},' + + '"WITH_ASSET_BURN":{"type":"bool","value":"false"}}', + ); + }); + + test("and identically from the other action that names the same covenant", async () => { + const claim = await derive("ClaimPrincipal", "principal_asset_auth", "./asset_auth.simf"); + const accept = await derive("AcceptOffer", "principal_asset_auth", "./asset_auth.simf"); + + expect(accept.result.ok).toBe(true); + expect(accept.calls[0]?.argumentsJson).toBe(claim.calls[0]?.argumentsJson); + }); +}); + +describe("the covenants behind repaying and the lender's settlement", () => { + test("the lender's finalised vault", async () => { + const { calls, result } = await derive( + "RepayLoan", + "lender_vault_finalized", + "./asset_auth_vault.simf", + ); + + expect(result.ok).toBe(true); + expect(calls[0]?.argumentsJson).toBe( + `{"VAULT_ASSET_ID":{"type":"u256","value":"${PRINCIPAL_COMMITTED}"},` + + `"KEEPER_AUTH_ASSET_ID":{"type":"u256","value":"${LENDER_COMMITTED}"},` + + `"SUPPLIER_AUTH_ASSET_ID":{"type":"u256","value":"${BORROWER_COMMITTED}"},` + + '"KEEPER_AUTH_ASSET_AMOUNT":{"type":"u64","value":"1"},' + + `"FINALIZED_VAULT_COV_HASH":{"type":"u256","value":"0x${ZERO_HASH}"},` + + '"IS_ACTIVE":{"type":"bool","value":"false"},' + + '"WITH_KEEPER_ASSET_BURN":{"type":"bool","value":"true"},' + + '"WITH_SUPPLIER_ASSET_BURN":{"type":"bool","value":"true"}}', + ); + }); + + /** + * The same contract, wired differently. Its keeper burns nothing where the lender's vault + * burns, which is one word in the document and a different covenant at a different address — + * so a flag read as a flag rather than as a declared type would put the protocol's fees + * somewhere nobody could spend them. + */ + test("the protocol fee's finalised vault, which differs from it by one flag", async () => { + const { calls, result } = await derive( + "RepayLoan", + "protocol_fee_vault_finalized", + "./asset_auth_vault.simf", + ); + + expect(result.ok).toBe(true); + expect(calls[0]?.argumentsJson).toBe( + `{"VAULT_ASSET_ID":{"type":"u256","value":"${PRINCIPAL_COMMITTED}"},` + + `"KEEPER_AUTH_ASSET_ID":{"type":"u256","value":"${FEE_KEEPER_COMMITTED}"},` + + `"SUPPLIER_AUTH_ASSET_ID":{"type":"u256","value":"${BORROWER_COMMITTED}"},` + + '"KEEPER_AUTH_ASSET_AMOUNT":{"type":"u64","value":"1"},' + + `"FINALIZED_VAULT_COV_HASH":{"type":"u256","value":"0x${ZERO_HASH}"},` + + '"IS_ACTIVE":{"type":"bool","value":"false"},' + + '"WITH_KEEPER_ASSET_BURN":{"type":"bool","value":"false"},' + + '"WITH_SUPPLIER_ASSET_BURN":{"type":"bool","value":"true"}}', + ); + }); +}); + +describe("what it still refuses rather than getting wrong", () => { + /** + * The state every one of these was in before, reproduced by withholding the contract. The + * values are unchanged and none of them is readable: `1` is not a number until something + * says at what width, and a width is part of the address. + */ + test("all three, when nothing says what the contract declares", async () => { + const results = await Promise.all( + ( + [ + ["ClaimPrincipal", "principal_asset_auth", "./asset_auth.simf"], + ["RepayLoan", "lender_vault_finalized", "./asset_auth_vault.simf"], + ["RepayLoan", "protocol_fee_vault_finalized", "./asset_auth_vault.simf"], + ] as const + ).map(([action, utxoType, source]) => { + const found = findAction(manifest, action); + + if (!found) { + throw new Error(`This fixture declares no action named ${action}.`); + } + + return deriveCovenantAddress(manifest, { + compile: () => ({ address: "ex1p", scriptPubKeyHex: "51" }), + contractSources: { [source]: contract(source.replace("./", "")) }, + declaredTypes: declaredParamTypes(manifest, found), + includeDebugSymbols: buildMode(manifest), + network: "liquid", + scope: { instance: INSTANCE, params: {} }, + utxoType, + wiring: {}, + }); + }), + ); + + expect(results.map((result) => result.ok)).toEqual([false, false, false]); + }); + + /** + * A contract that will not analyse is reported the way one that will not compile is. It is + * the same failure found one step earlier, and saying so keeps the two from reading as + * different problems. + */ + test("a contract whose declarations cannot be read at all", async () => { + const found = findAction(manifest, "ClaimPrincipal"); + const result = await deriveCovenantAddress(manifest, { + compile: () => ({ address: "ex1p", scriptPubKeyHex: "51" }), + contractParamTypes: () => { + throw new Error("not a program"); + }, + contractSources: { "./asset_auth.simf": contract("asset_auth.simf") }, + declaredTypes: declaredParamTypes(manifest, found!), + includeDebugSymbols: buildMode(manifest), + network: "liquid", + scope: { instance: INSTANCE, params: {} }, + utxoType: "principal_asset_auth", + wiring: {}, + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("./asset_auth.simf"); + }); +}); diff --git a/packages/tx-manifest/src/document/asset.test.ts b/packages/tx-manifest/src/document/asset.test.ts new file mode 100644 index 0000000..2f2286e --- /dev/null +++ b/packages/tx-manifest/src/document/asset.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; + +import { identifiedForeignAsset, refuseUnfundableAsset, statedAsset } from "./asset"; +import { findAction, normaliseManifest } from "./normalise"; + +// AC-06. The rule that decides which asset a document is talking about, and the rule that +// decides whether this wallet can pay in it. They used to be one check that could only answer +// the first question and was asked the second. + +const POLICY = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +/** Another real Liquid asset id. It begins with a letter, which is the whole reason it is here. */ +const OTHER = "feb3d9c9f2a9aaab816c2e93cfd4479f841b8e05596b8418ed0fd56e0b8d2e6d"; + +describe("what a document has said about an asset", () => { + test.each([["lbtc"], ["LBTC"], [POLICY], [POLICY.toUpperCase()]])( + "%s is the network's own asset", + (declared) => { + expect(statedAsset(declared, POLICY).kind).toBe("network"); + }, + ); + + test.each([["instance.X"], ["compile_params.X"], ["params.X"], ["args.X"], ["BARE"]])( + "%s is a lookup the document has deferred", + (declared) => { + expect(statedAsset(declared, POLICY).kind).toBe("deferred"); + }, + ); + + // The dex protocol writes this: "the asset that input arrived in", named without naming it. + test("an attribute of another input is deferred too", () => { + expect(statedAsset("payout_in.asset", POLICY).kind).toBe("deferred"); + }); + + test("an asset id the document names outright is identified", () => { + expect(statedAsset(OTHER, POLICY)).toEqual({ id: OTHER, kind: "identified" }); + }); + + // The trap this ordering exists for. An asset id is a run of `[a-f0-9]`, and a bare + // reference is a run of `[A-Za-z0-9_]`, so every id beginning with a letter is also a + // syntactically perfect reference to something with a sixty-four-character name. Asking the + // reference parser first gets this backwards on real money, and the corpus contains ids of + // both shapes. + test("an asset id beginning with a letter is an id, not a reference to something named that", () => { + expect(statedAsset(OTHER, POLICY).kind).toBe("identified"); + expect(statedAsset(`2${OTHER.slice(1)}`, POLICY).kind).toBe("identified"); + }); + + // Refused rather than deferred, which is the safe direction: a spelling nothing can resolve + // must not be handed to a later check that will never manage to run. + test("text that is neither an id nor a lookup is treated as an asset it named", () => { + expect(statedAsset("not a reference!", POLICY).kind).toBe("identified"); + }); +}); + +/** One action declaring the same asset at an input and an output. */ +function actionMoving(asset: string) { + const { manifest } = normaliseManifest({ + actions: { + Move: { + inputs: [{ asset, id: "in_one", utxo_source: "wallet" }], + outputs: [{ amount_sat: 1000, asset, destination: "wallet", id: "out_one" }], + }, + }, + chain: "liquid", + }); + const action = findAction(manifest, "Move"); + + if (!action) { + throw new Error("expected a Move action"); + } + + return action; +} + +describe("what the document alone can settle", () => { + test("an asset the document names, and this wallet does not move", () => { + expect(identifiedForeignAsset(actionMoving(OTHER), POLICY)).toEqual({ + asset: OTHER, + at: "in_one", + }); + }); + + test("and nothing, when the document named the network's own asset", () => { + expect(identifiedForeignAsset(actionMoving("lbtc"), POLICY)).toBeUndefined(); + }); + + // The correction. A lookup is not an asset yet, and refusing one claims the action moves + // money this wallet cannot — which reading the document has not established. + test("and nothing, when the document deferred the answer", () => { + expect(identifiedForeignAsset(actionMoving("instance.PRINCIPAL"), POLICY)).toBeUndefined(); + }); +}); + +describe("whether this wallet can fund the action, once the lookups resolve", () => { + test("it cannot, when the lookup resolves to an asset it does not hold", () => { + const refusal = refuseUnfundableAsset(actionMoving("instance.PRINCIPAL"), POLICY, { + instance: { PRINCIPAL: OTHER }, + params: {}, + }); + + expect(refusal).toContain(OTHER); + expect(refusal).toContain("funds an action only in the network's own asset"); + }); + + test.each([[POLICY], ["lbtc"]])("it can, when the lookup resolves to %s", (resolved) => { + expect( + refuseUnfundableAsset(actionMoving("instance.PRINCIPAL"), POLICY, { + instance: { PRINCIPAL: resolved }, + params: {}, + }), + ).toBeUndefined(); + }); + + // Not knowing what is being paid in is exactly the moment not to pay. A lookup with nothing + // to resolve against is refused rather than waved through as "no foreign asset found". + test("it will not, when the lookup cannot be resolved at all", () => { + expect( + refuseUnfundableAsset(actionMoving("instance.PRINCIPAL"), POLICY, { params: {} }), + ).toContain("could not establish what that is"); + }); + + test("nor when the lookup resolves to something that is not an asset id", () => { + expect( + refuseUnfundableAsset(actionMoving("instance.PRINCIPAL"), POLICY, { + instance: { PRINCIPAL: 7 }, + params: {}, + }), + ).toContain("not an asset id"); + }); + + test("and an asset named outright is still refused here as well", () => { + expect(refuseUnfundableAsset(actionMoving(OTHER), POLICY, { params: {} })).toContain(OTHER); + }); + + test("while the network's own asset needs no lookup and is funded", () => { + expect(refuseUnfundableAsset(actionMoving("lbtc"), POLICY, { params: {} })).toBeUndefined(); + }); +}); diff --git a/packages/tx-manifest/src/document/asset.ts b/packages/tx-manifest/src/document/asset.ts new file mode 100644 index 0000000..962d4b5 --- /dev/null +++ b/packages/tx-manifest/src/document/asset.ts @@ -0,0 +1,172 @@ +import { asArray, asRecord } from "./json"; +import type { NormalisationNote, NormalisedAction } from "./normalise"; +import { parseReference, type ReferenceScope, resolveReference } from "./references"; + +/** + * What a document has actually said about the asset a piece of value is in. + * + * Three different statements, and the format writes all three as a plain string. The first two + * are the document committing to an asset. The third is it deferring the answer to a file the + * document does not contain — this deployment's fields, or the request's own parameters. + * + * Telling them apart is the whole point. A runtime that reads a deferred lookup as a committed + * asset is answering a question the document has not asked yet, and the answer it reaches is + * about the spelling rather than about the money. + */ +export type StatedAsset = + /** A lookup this document leaves to be resolved later. */ + | { kind: "deferred"; reference: string } + /** An asset this document names outright, and which is not this network's own. */ + | { kind: "identified"; id: string } + /** The asset this network charges its fees in, however the document spelled it. */ + | { kind: "network" }; + +/** The word every generation of the format uses for the asset its network charges fees in. */ +const NETWORK_ASSET = "lbtc"; + +/** + * An asset id as the format writes one: thirty-two bytes of hex and nothing else. + * + * Tested before the text is offered to the reference parser, and that order is load-bearing. A + * bare reference and an asset id are both runs of `[A-Za-z0-9_]` to a parser, so an id that + * happens to begin with a letter — this project already has `feb3d9…` on file — parses as a + * perfectly good reference to something named `feb3d9…`. Length and alphabet separate the two. + * Nothing else does, and asking the parser first gets the answer backwards on real ids. + */ +const ASSET_ID = /^[0-9a-f]{64}$/; + +/** + * Reads what a document has said about one asset. + * + * The network's own asset is accepted under either spelling the corpus uses: the keyword, and + * the id itself. They are the same asset and a document may write either. + * + * Anything that is neither the network's asset nor a resolvable lookup is treated as an asset + * the document identified, which is the safe direction — an unrecognisable spelling is refused + * rather than deferred into a check that will never be reached. + */ +export function statedAsset(declared: string, policyAsset: string): StatedAsset { + const text = declared.trim(); + const lowered = text.toLowerCase(); + + if (lowered === NETWORK_ASSET || lowered === policyAsset.trim().toLowerCase()) { + return { kind: "network" }; + } + + if (ASSET_ID.test(lowered)) { + return { id: lowered, kind: "identified" }; + } + + return parseReference(text) + ? { kind: "deferred", reference: text } + : { id: text, kind: "identified" }; +} + +/** + * Every asset an action states, in the order a person reading the document would meet them. + * + * Inputs and outputs both, because value arriving in an asset and value leaving in one are the + * same fact about the transaction from opposite ends. + */ +function statedAssets(action: NormalisedAction): { at: string; declared: string }[] { + const found: { at: string; declared: string }[] = []; + + for (const kind of ["inputs", "outputs"] as const) { + for (const entry of asArray(action.node[kind])) { + const node = asRecord(entry); + const declared = node?.asset; + + if (typeof declared === "string") { + found.push({ at: typeof node?.id === "string" ? node.id : "(unnamed)", declared }); + } + } + } + + return found; +} + +/** + * The asset a document commits to that this wallet does not move, if it commits to one. + * + * This is everything the document alone can settle. A deferred lookup is not an asset yet, and + * refusing one says the document moves an asset this wallet cannot — which is a claim about + * money that nothing has established. The rest is decided by `refuseUnfundableAsset`, once the + * deployment's fields have been read and the lookup resolves to something. + */ +export function identifiedForeignAsset( + action: NormalisedAction, + policyAsset: string, +): { asset: string; at: string } | undefined { + for (const { at, declared } of statedAssets(action)) { + const stated = statedAsset(declared, policyAsset); + + if (stated.kind === "identified") { + return { asset: stated.id, at }; + } + } + + return undefined; +} + +/** + * Why this wallet cannot fund the action, once every asset it states has resolved. + * + * This is the rule the document-level check was reaching for and could not implement, because + * the answer is not in the document. It is enforced here, against resolved ids. + * + * The rule is arithmetic rather than taste. This runtime keeps one running total: it nets what + * the covenants it spends already hold against what the action's outputs cost, and funds the + * remainder from the wallet, which holds the asset the network charges fees in. Netting two + * assets into one total is only sound when there is one asset. So a second one is refused here + * rather than added to a sum that would no longer mean anything — and a lookup that cannot be + * resolved is refused with it, because not knowing what is being paid in is exactly the moment + * not to pay. + */ +export function refuseUnfundableAsset( + action: NormalisedAction, + policyAsset: string, + scope: ReferenceScope, + notes?: NormalisationNote[], +): string | undefined { + for (const { at, declared } of statedAssets(action)) { + const stated = statedAsset(declared, policyAsset); + + if (stated.kind === "network") { + continue; + } + + if (stated.kind === "identified") { + return ( + `${action.name} moves ${stated.id} at ${at}, and this wallet funds an action only ` + + "in the network's own asset." + ); + } + + const found = resolveReference(stated.reference, "asset", scope, notes); + + if (!found.ok) { + return ( + `${action.name} states the asset at ${at} as ${stated.reference}, and this wallet ` + + `could not establish what that is: ${found.reason}` + ); + } + + if (typeof found.value !== "string") { + return ( + `${action.name} states the asset at ${at} as ${stated.reference}, which resolved to ` + + "something that is not an asset id." + ); + } + + const resolved = statedAsset(found.value, policyAsset); + + if (resolved.kind !== "network") { + return ( + `${action.name} moves ${found.value} at ${at}, which ${stated.reference} resolves ` + + "to, and this wallet funds an action only in the network's own asset." + ); + } + } + + return undefined; +} diff --git a/packages/tx-manifest/src/document/normalise.ts b/packages/tx-manifest/src/document/normalise.ts index e036a6a..c4e9fe4 100644 --- a/packages/tx-manifest/src/document/normalise.ts +++ b/packages/tx-manifest/src/document/normalise.ts @@ -282,6 +282,37 @@ const CONTAINERS = [ { holder: "contract_templates", holds: "actions" }, ] as const; +/** + * What a deployment of this action's contract declares about its own fields. + * + * A deployment's fields are declared once on the container and filled in per deployment, so the + * container is where a field's type is stated — there is nowhere else. They are read through + * the same container list the actions were found through rather than through a name of their + * own, because the rename that hid every one of these documents renamed both halves at once. + * + * Empty for a free action, which belongs to no container and therefore to no deployment. + */ +export function declaredFields( + manifest: NormalisedManifest, + action: NormalisedAction, +): Record<string, unknown> { + if (action.boundTo === undefined) { + return {}; + } + + for (const container of CONTAINERS) { + const fields = asRecord( + asRecord(asRecord(manifest.raw[container.holder])?.[action.boundTo])?.fields, + ); + + if (fields) { + return fields; + } + } + + return {}; +} + function normaliseAction( name: string, declared: Record<string, unknown>, diff --git a/packages/tx-manifest/src/document/references.test.ts b/packages/tx-manifest/src/document/references.test.ts index 5eae540..6bed512 100644 --- a/packages/tx-manifest/src/document/references.test.ts +++ b/packages/tx-manifest/src/document/references.test.ts @@ -99,6 +99,37 @@ describe("resolveReference", () => { expect(resolveReference("pubkey", "destination", SCOPE).ok).toBe(false); }); + // The bytes an output publishes about the action are a record that outlives the + // transaction, and the wallet's fee is a figure it models before anything is signed — + // the module that signs weighs the finished transaction and charges its own. Naming the + // fee here would publish, permanently, a number the transaction did not pay. + test("a data part may not come from the fee", () => { + const result = resolveReference("fee", "dataPart", SCOPE); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("a data part"); + }); + + // The fee is the only form it lost against the expression site it used to share. Every + // other name a payload can carry is settled by the time the bytes are written. + test("a data part may come from anything settled before the bytes are", () => { + expect(resolveReference("instance.OWNER", "dataPart", SCOPE).ok).toBe(true); + expect(resolveReference("params.pubkey", "dataPart", SCOPE).ok).toBe(true); + expect(resolveReference("args.note", "dataPart", SCOPE).ok).toBe(true); + expect(resolveReference("shared", "dataPart", SCOPE).ok).toBe(true); + expect(resolveReference("p2pk_in.amount_sat", "dataPart", SCOPE).ok).toBe(true); + }); + + // A validation is a check being made now rather than a record being written, so the + // site the payload used to share keeps the fee. Narrowing that one would have taken it. + test("a validation's expression may still come from the fee", () => { + expect(resolveReference("fee", "expression", SCOPE)).toEqual({ + form: "fee", + ok: true, + value: 500n, + }); + }); + test("says which site refused it, so a refusal can be read", () => { const result = resolveReference("fee", "compileParam", SCOPE); diff --git a/packages/tx-manifest/src/document/references.ts b/packages/tx-manifest/src/document/references.ts index cfbf2d1..d7dd1d0 100644 --- a/packages/tx-manifest/src/document/references.ts +++ b/packages/tx-manifest/src/document/references.ts @@ -69,9 +69,12 @@ export type ReferenceResolution = */ export type ReferenceSiteKind = | "amount" + | "asset" | "compileParam" + | "dataPart" | "destination" | "expression" + | "extraLeaf" | "issuedAmount" | "witnessKey" | "witnessValue"; @@ -82,11 +85,46 @@ const SITES: Record<ReferenceSiteKind, { accepts: ReferenceForm[]; describes: st accepts: ["fee", "instance", "params", "args", "input-attribute", "bare"], describes: "an amount", }, + /** + * The asset an input or output carries. + * + * The fee is absent because the fee is a number of the network's own asset, and an asset is + * not a quantity of anything. Every other form is here because the corpus writes all of + * them: this deployment's fields, the request's parameters and arguments, a bare name, and + * an attribute of an input the wallet already resolved — `payout_in.asset`, which says "the + * same asset that one arrived in" without naming it. + */ + asset: { + accepts: ["instance", "params", "args", "input-attribute", "bare"], + describes: "an asset", + }, /** A value compiled into a contract, which therefore decides its address. */ compileParam: { accepts: ["instance", "params", "args", "bare"], describes: "a compile parameter", }, + /** + * A value inside the bytes an output publishes about the action. + * + * The fee is absent, and the reason is not the one that keeps it out of a compile + * parameter. Nothing here is circular: the fee a part could carry only ever lands in one + * of this vocabulary's fixed-width integers, so the payload's length does not move with + * its value, and the wallet's own estimate prices an output by the fact that it exists + * rather than by how many bytes it carries. What is wrong is the number itself. The + * wallet's figure is a model made before anything is signed, and the module that signs + * weighs the finished transaction and charges its own; the two differ by construction. An + * amount computed from the model is absorbed, because the difference lands in change — a + * published record is absorbed by nothing. It would state on chain, permanently, a fee the + * transaction did not pay, and no reader of those bytes could tell. + * + * Everything the expression site accepts other than the fee is here, because a payload + * naming this deployment's fields, the request, or something the wallet read about an + * input is naming a figure already settled when the bytes are written. + */ + dataPart: { + accepts: ["instance", "params", "args", "input-attribute", "bare"], + describes: "a data part", + }, /** Where an output pays, when it names a parameter rather than a keyword. */ destination: { accepts: ["params"], describes: "a destination" }, /** A validation's expression. Evaluating the expression is a later slice; this resolves one term of it. */ @@ -94,6 +132,20 @@ const SITES: Record<ReferenceSiteKind, { accepts: ReferenceForm[]; describes: st accepts: ["fee", "instance", "params", "args", "input-attribute", "bare"], describes: "an expression", }, + /** + * A value inside one of a covenant's extra taproot leaves. + * + * The leaves are part of the tree the covenant's address is derived from, so this position + * accepts what a compile parameter accepts and for the same reasons. The fee is absent + * because it is worked out from a transaction that pays to the address this decides, which + * is circular. An attribute of a resolved input is absent for a sharper version of the same + * problem: it is read from the chain at the outpoint this covenant is being derived in order + * to check, so a leaf reading one would be checking an address against itself. + */ + extraLeaf: { + accepts: ["instance", "params", "args", "bare"], + describes: "an extra taproot leaf", + }, /** * How many units an issuance creates, which is not an amount anyone pays. * diff --git a/packages/tx-manifest/src/document/refuse.test.ts b/packages/tx-manifest/src/document/refuse.test.ts index 61731fc..31ea206 100644 --- a/packages/tx-manifest/src/document/refuse.test.ts +++ b/packages/tx-manifest/src/document/refuse.test.ts @@ -42,6 +42,13 @@ describe("an unrecognised construct in a load-bearing position", () => { expect(refuse({ attestation_version: "1" })).toBe(""); }); + // The pointer a deployed document writes as `$comment_schema` rather than `$schema`, so + // its own validator leaves it alone. Refusing it refused every action in that document, + // at its very first key, for a value nothing reads. + test("says nothing about a schema pointer written as a comment", () => { + expect(refuse({ $comment_schema: "https://example.invalid/elip205.json" })).toBe(""); + }); + test("says nothing about an unrecognised key inside a display block", () => { expect(refuse({ actions: { Pay: { ui: { icon: "vault.svg" } } } })).toBe(""); }); diff --git a/packages/tx-manifest/src/document/refuse.ts b/packages/tx-manifest/src/document/refuse.ts index b974b85..12c12e6 100644 --- a/packages/tx-manifest/src/document/refuse.ts +++ b/packages/tx-manifest/src/document/refuse.ts @@ -1,4 +1,5 @@ import { STATIC_WITNESS } from "../evaluation/witness"; +import { identifiedForeignAsset, statedAsset } from "./asset"; import { asArray, asRecord } from "./json"; import type { NormalisedManifest } from "./normalise"; import { loadBearing, inspectConstructs } from "./registry"; @@ -417,35 +418,36 @@ function refuseUnproducibleWitness(manifest: NormalisedManifest): Refusal | unde } /** - * An input or output in an asset this wallet does not move. + * An input or output in an asset this document names and this wallet does not move. * - * Only the policy asset today. An input naming another asset would be funded from L-BTC and - * an output in another asset would be paid in L-BTC, and neither is a smaller version of - * what the manifest asked for — it is a different transaction. + * Only what the document itself settles. The format lets an asset be written as an id or as a + * lookup into this deployment's fields, and a lookup is not an asset yet — the document has + * said where the answer will come from and nothing about what it is. Refusing one says the + * action moves an asset this wallet cannot, which is a statement about money that reading the + * document has not established. + * + * That distinction is not academic here. Across every published manifest this project has on + * file, not one asset is written as an id: they are `lbtc` or they are lookups. So a rule that + * refuses any text which is not literally the network's asset refuses on the spelling every + * time it fires, and has never once refused an asset. + * + * What the wallet can and cannot fund is decided against resolved ids by + * `refuseUnfundableAsset`, once the deployment's fields have been read. */ function refuseForeignAsset( manifest: NormalisedManifest, policyAsset: string, ): Refusal | undefined { - const allowed = new Set(["lbtc", policyAsset.toLowerCase()]); - for (const action of manifest.actions) { - for (const kind of ["inputs", "outputs"] as const) { - for (const declared of asArray(action.node[kind])) { - const entry = asRecord(declared); - const asset = entry?.asset; - - if (typeof asset === "string" && !allowed.has(asset.toLowerCase())) { - const id = typeof entry?.id === "string" ? entry.id : "(unnamed)"; + const found = identifiedForeignAsset(action, policyAsset); - return { - reason: - `${action.name} moves ${asset} at ${id}, and this wallet moves only the ` + - "network's own asset.", - reject: "foreign-asset", - }; - } - } + if (found) { + return { + reason: + `${action.name} moves ${found.asset} at ${found.at}, and this wallet moves only ` + + "the network's own asset.", + reject: "foreign-asset", + }; } } @@ -464,8 +466,6 @@ function refuseUnbuildableUtxoType( manifest: NormalisedManifest, policyAsset: string, ): Refusal | undefined { - const allowed = new Set(["lbtc", policyAsset.toLowerCase()]); - for (const [name, declared] of Object.entries(manifest.utxoTypes)) { const utxoType = asRecord(declared); const scriptType = asRecord(utxoType?.script)?.type; @@ -489,12 +489,16 @@ function refuseUnbuildableUtxoType( } const asset = utxoType?.asset; + const stated = typeof asset === "string" ? statedAsset(asset, policyAsset) : undefined; - if (typeof asset === "string" && !allowed.has(asset.toLowerCase())) { + // The same distinction the asset refusal draws, for the same reason: a covenant whose + // asset is a lookup has not yet said what it holds, and every covenant in the published + // corpus states its asset that way. + if (stated?.kind === "identified") { return { reason: - `The ${name} covenant holds ${asset}, and this wallet moves only the network's own ` + - "asset.", + `The ${name} covenant holds ${stated.id}, and this wallet moves only the network's ` + + "own asset.", reject: "unbuildable-utxo-type", }; } diff --git a/packages/tx-manifest/src/document/registry.test.ts b/packages/tx-manifest/src/document/registry.test.ts index 69312c0..f4e6041 100644 --- a/packages/tx-manifest/src/document/registry.test.ts +++ b/packages/tx-manifest/src/document/registry.test.ts @@ -61,6 +61,20 @@ describe("inspectConstructs", () => { expect(at(findings, "label")).toBeUndefined(); }); + /* + * A deployed document writes its schema pointer as `$comment_schema`, which is the + * statement `$schema` makes with the word "comment" in the key. It decides nothing, + * and being unknown made it refuse the whole document at its first key. + */ + test("reports a schema pointer written as a comment as ignored, not load-bearing", () => { + const finding = at( + inspect({ $comment_schema: "https://example.invalid/elip205.json" }), + "$comment_schema", + ); + + expect(finding).toMatchObject({ at: "manifest", declared: true, loadBearing: false }); + }); + test("collects the ignored ones for reporting", () => { expect(ignored(inspect(flat)).map((finding) => finding.key)).toContain("attestation_version"); }); diff --git a/packages/tx-manifest/src/document/registry.ts b/packages/tx-manifest/src/document/registry.ts index 199ef0b..11d92f4 100644 --- a/packages/tx-manifest/src/document/registry.ts +++ b/packages/tx-manifest/src/document/registry.ts @@ -56,6 +56,15 @@ export type ConstructReport = { /** Where it was found, in the document's own terms. */ at: string; key: string; + /** + * The kind of position it sits at, as the table itself keys them. + * + * Reported because `at` is written for a person — "action Pay / param amount_sat" — and a + * caller that needs to know two reports concern the same construct has otherwise to parse + * that sentence, which makes a display string into a contract nobody declared. The same key + * at two kinds of position is two constructs and may be in two different states. + */ + site: ConstructSiteKind; state: ConstructState; }; @@ -74,13 +83,69 @@ export function describeConstructs(manifest: NormalisedManifest): ConstructRepor const site: ConstructSite = SITES[kind]; for (const key of Object.keys(node)) { - reports.push({ at, key, state: stateOf(site, key) }); + reports.push({ at, key, site: kind, state: stateOf(site, key) }); } }); return reports; } +/** One construct the runtime registers, for a caller holding no document. */ +export type ConstructRegistryEntry = { + key: string; + /** Why the runtime does not act on it, or undefined where it does. */ + reason: string | undefined; + /** The kind of position, or undefined where the key is answered at every position. */ + site: ConstructSiteKind | undefined; + state: ConstructState; +}; + +/** + * Every construct this runtime knows, whether or not anyone has pasted a document. + * + * The companion to {@link describeConstructs} from the other side. That one answers "what is + * in this document"; this one answers "what can this runtime honour at all", which no document + * can answer, because a construct nobody has published is invisible in every document there + * is. All seven that the format defines and this runtime does not implement are in exactly + * that position today: no published protocol uses one, so every document ever inspected here + * has read clean while the seven stood. + * + * Unsorted. A caller ordering it knows what its reader came for; the table's own order is the + * order somebody typed it in. + */ +export function describeRegistry(): ConstructRegistryEntry[] { + const entries: ConstructRegistryEntry[] = []; + + for (const [kind, site] of Object.entries(SITES) as [ConstructSiteKind, ConstructSite][]) { + for (const [key, construct] of Object.entries(site.constructs)) { + entries.push(entryOf(key, kind, construct)); + } + } + + for (const [key, construct] of Object.entries(DOCUMENT_CONVENTIONS)) { + entries.push(entryOf(key, undefined, construct)); + } + + return entries; +} + +function entryOf( + key: string, + site: ConstructSiteKind | undefined, + construct: Construct, +): ConstructRegistryEntry { + if (construct.handled) { + return { key, reason: undefined, site, state: construct.loadBearing ? "acted-on" : "shown" }; + } + + return { + key, + reason: construct.reason, + site, + state: construct.loadBearing ? "unimplemented" : "never-read", + }; +} + function stateOf(site: ConstructSite, key: string): ConstructState { const construct = constructAt(site, key); @@ -103,20 +168,43 @@ function stateOf(site: ConstructSite, key: string): ConstructState { * visible here, so the gap between what the format can say and what the wallet can honour * is a table to read rather than an absence to notice. */ -type Construct = { - /** Whether the runtime acts on it today. An unhandled one becomes a finding. */ - handled: boolean; - /** Whether reading it wrong could change what gets signed. */ - loadBearing: boolean; -}; +type Construct = + | { + /** The runtime acts on it today. */ + handled: true; + /** Whether reading it wrong could change what gets signed. */ + loadBearing: boolean; + } + | { + /** The runtime does not act on it, so it becomes a finding. */ + handled: false; + loadBearing: boolean; + /** + * Why this runtime does not act on it, for a reader who is not looking at this file. + * + * Required rather than optional, which is the whole of what makes it worth having: a + * construct added here without one does not compile, so the gap cannot be widened in + * silence. Where nobody ever wrote the reason down, the honest text says exactly that — + * an unexplained gap is a fact about this project and reads as one. + */ + reason: string; + }; const READ: Construct = { handled: true, loadBearing: true }; const SHOWN: Construct = { handled: true, loadBearing: false }; -const UNIMPLEMENTED: Construct = { handled: false, loadBearing: true }; -const UNREAD: Construct = { handled: false, loadBearing: false }; + +/** The format defines it, this runtime does not implement it, and being wrong changes money. */ +function unimplemented(reason: string): Construct { + return { handled: false, loadBearing: true, reason }; +} + +/** Known, deliberately read by nothing, and unable to change what gets signed. */ +function unread(reason: string): Construct { + return { handled: false, loadBearing: false, reason }; +} /** - * The two keys that belong to JSON documents rather than to this format. + * The keys that belong to JSON documents rather than to this format. * * A comment and a pointer to a schema file can appear at any depth, decide nothing anywhere, * and are put there by whatever wrote or edits the document. Listing them at every position @@ -124,8 +212,19 @@ const UNREAD: Construct = { handled: false, loadBearing: false }; * someone uses one at, so they are answered once here. */ const DOCUMENT_CONVENTIONS: Record<string, Construct> = { - $comment: UNREAD, - $schema: UNREAD, + $comment: unread( + "A comment, put there by whatever wrote or edits the document. It can appear at any depth and decides nothing anywhere.", + ), + // The same statement `$schema` makes, spelled by a document whose own tooling validates + // against `$schema` and which wanted the pointer kept without being validated on. Named + // here rather than matched by prefix: this is one more key that is known, and a rule + // accepting anything opening with `$comment` would accept a key nobody has read. + $comment_schema: unread( + "A pointer to a schema file, written as a comment so a validator leaves it alone. It decides nothing, exactly as $schema decides nothing.", + ), + $schema: unread( + "A pointer to a schema file, put there by whatever wrote or edits the document. It can appear at any depth and decides nothing anywhere.", + ), }; /** What a position says about one key, or what every position says about it. */ @@ -162,33 +261,35 @@ type ConstructSite = { const SITES = { action: { constructs: { - args: UNIMPLEMENTED, + args: unimplemented( + "Nothing in this runtime reads it, and no note here says why. The gap is real and unexplained.", + ), create_instance: READ, description: SHOWN, inputs: READ, - // A sentence saying what this action does, written for whoever approves it, beside the - // shorter `description`. It decides nothing that gets signed. Not shown: its text - // interpolates values from the deployment and the request through a syntax no - // specification describes, and a confident sentence about the wrong amounts changes - // what a person agrees to. - intent: UNREAD, - // The older generation's flag beside the block. The newer one dropped it, on the - // ground that an action carrying the block is the constructor and a flag adds - // nothing; six of the corpus's eleven constructors carry both and five carry only - // the block. So it is read for nothing, which is different from being ignored: - // what it asserts is asserted better by the block beside it. - is_constructor: UNREAD, - on_input_resolved: UNIMPLEMENTED, - on_post_broadcast: UNIMPLEMENTED, + intent: unread( + "A sentence saying what this action does, written for whoever approves it, beside the shorter description. Not shown: its text interpolates values from the deployment and the request through a syntax no specification describes, and a confident sentence about the wrong amounts changes what a person agrees to.", + ), + is_constructor: unread( + "The older generation's flag beside the create_instance block. The newer one dropped it, on the ground that an action carrying the block is the constructor and a flag adds nothing. Six of the corpus's eleven constructors carry both and five carry only the block, so what it asserts is asserted better by the block beside it.", + ), + on_input_resolved: unimplemented( + "A hook the legacy hooks block held, alongside on_validate, before both moved onto the action. Nothing in this runtime runs it, and no note here says why.", + ), + on_post_broadcast: unimplemented( + "The counterpart of on_pre_broadcast, which this runtime does run. Nothing here runs this one, and no note says why.", + ), on_pre_broadcast: READ, - // A full SimplicityHL program, not a formula: honouring it means executing a - // contract at build time. Out of scope for this change and named rather than absent. - on_validate: UNIMPLEMENTED, + on_validate: unimplemented( + "A full SimplicityHL program rather than a formula: honouring it means executing a contract at build time. Out of scope when the runtime was built, and named here rather than left absent.", + ), outputs: READ, params: READ, ui: SHOWN, validations: READ, - witnesses: UNIMPLEMENTED, + witnesses: unimplemented( + "An action-level witness block. Witnesses on an input are read, and what is and is not honoured inside one is settled at the witness position; nothing reads this outer block, and no note here says why.", + ), }, unknownIsLoadBearing: true, }, @@ -223,9 +324,9 @@ const SITES = { }, manifest: { constructs: { - // Reserved for a signature slot that does not exist, and read by no - // implementation including the reference one. - attestation_version: UNREAD, + attestation_version: unread( + "Reserved for a signature slot that does not exist, and read by no implementation including the reference one.", + ), actions: READ, chain: READ, classes: READ, @@ -248,13 +349,9 @@ const SITES = { // already acts on. simplicity_hl: READ, simplicity_hl_version: READ, - // One line in the published specification — "relative path to the top-level .simf - // file" — and nothing anywhere says what a runtime does with it. The newer schema - // dropped it from the top level entirely, the reference implementation reads no such - // field, and no published manifest carries one: a covenant's source is named on the - // covenant, where it decides an address. So it decides nothing here, and refusing a - // document for carrying it would be refusing for a field the format has abandoned. - source: UNREAD, + source: unread( + 'One line in the published specification — "relative path to the top-level .simf file" — and nothing anywhere says what a runtime does with it. The newer schema dropped it from the top level entirely, the reference implementation reads no such field, and no published manifest carries one: a covenant\'s source is named on the covenant, where it decides an address. Refusing a document for carrying it would be refusing for a field the format has abandoned.', + ), utxo_types: READ, }, unknownIsLoadBearing: true, @@ -263,7 +360,9 @@ const SITES = { constructs: { amount_sat: READ, asset: READ, - condition: UNIMPLEMENTED, + condition: unimplemented( + "A condition deciding whether this output is produced at all. Nothing in this runtime evaluates it, and no note here says why.", + ), // Whether this output hides what it carries. The wallet hides it with its own // blinding key; one paid to an address the document names refuses, because the key // there belongs to whoever owns the address. @@ -287,11 +386,13 @@ const SITES = { // The literal used when nothing supplied a value, which is the last of the three // steps and never overwrites one a person chose. default: READ, - derived: UNIMPLEMENTED, + derived: unimplemented( + "A parameter derived from something else rather than supplied or computed. Nothing in this runtime derives it, and no note here says why.", + ), description: SHOWN, - // The reference implementation's own comment calls it informational only for - // display, so it does not decide a value and cannot change what is signed. - formula: UNREAD, + formula: unread( + "The reference implementation's own comment calls it informational only, for display, so it does not decide a value and cannot change what is signed.", + ), // The oldest generation's spelling of a value the wallet supplies. Read together // with the newer one, so a refusal names the thing rather than the spelling. source: READ, @@ -363,7 +464,16 @@ const SITES = { }, } satisfies Record<string, ConstructSite>; -type SiteKind = keyof typeof SITES; +/** + * Every kind of position this format has, as the construct table itself keys them. + * + * Published because a caller reporting on a document needs to say which kind a construct was + * found at without reading the sentence written for a person, and because the set is the + * table's own rather than a second list that could fall behind it. + */ +export type ConstructSiteKind = keyof typeof SITES; + +type SiteKind = ConstructSiteKind; /** * Walks a normalised manifest and reports every construct the runtime does not act on. diff --git a/packages/tx-manifest/src/evaluation/assetLedger.test.ts b/packages/tx-manifest/src/evaluation/assetLedger.test.ts new file mode 100644 index 0000000..12fefdd --- /dev/null +++ b/packages/tx-manifest/src/evaluation/assetLedger.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, test } from "bun:test"; + +import dexManifest from "../__fixtures__/current/dex.manifest.json"; +import lendingManifest from "../__fixtures__/current/lending_v3.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import { assetLedger, type HeldValue, resolveAsset } from "./assetLedger"; +import { planAction } from "./plan"; + +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + +describe("which asset a document is talking about", () => { + const context = { policyAsset: POLICY_ASSET, scope: { params: {} } }; + + test("the network's own asset, under either spelling the corpus uses", () => { + expect(resolveAsset("lbtc", "output out", context)).toEqual({ id: POLICY_ASSET, ok: true }); + expect(resolveAsset(POLICY_ASSET.toUpperCase(), "output out", context)).toEqual({ + id: POLICY_ASSET, + ok: true, + }); + }); + + // An output that says nothing about its asset is paying in the one asset every reader of the + // document already shares, which is the one the network charges its fees in. + test("and the same when a site states none at all", () => { + expect(resolveAsset(undefined, "output out", context)).toEqual({ + id: POLICY_ASSET, + ok: true, + }); + }); + + test("a lookup, once the deployment supplies it", () => { + expect( + resolveAsset("instance.PRINCIPAL", "output out", { + policyAsset: POLICY_ASSET, + scope: { instance: { PRINCIPAL: "aa".repeat(32) }, params: {} }, + }), + ).toEqual({ id: "aa".repeat(32), ok: true }); + }); + + // Not knowing what is being paid in is exactly the moment not to pay, so this stays a + // refusal and stays worded as one. + test("and a lookup nothing resolves is not an asset yet", () => { + const resolved = resolveAsset("instance.PRINCIPAL", "output out", context); + + expect(resolved.ok).toBe(false); + expect(resolved.ok ? "" : resolved.reason).toContain("could not establish"); + expect(resolved.ok ? "" : resolved.reason).toContain("instance.PRINCIPAL"); + }); + + test("a lookup resolving to something that is not an id is refused too", () => { + const resolved = resolveAsset("instance.PRINCIPAL", "output out", { + policyAsset: POLICY_ASSET, + scope: { instance: { PRINCIPAL: 7 }, params: {} }, + }); + + expect(resolved.ok ? "" : resolved.reason).toContain("not an asset id"); + }); +}); + +/** + * A deployment of the published lending protocol with every asset a different asset. + * + * Distinct ids on purpose: an instance that reused one id would make a ledger that never + * separated them look exactly like one that did. + */ +const LENDING = { + BORROWER_NFT_ASSET_ID: "b0".repeat(32), + COLLATERAL_AMOUNT: "500000", + COLLATERAL_ASSET_ID: "c0".repeat(32), + CURRENT_DEBT: "110000", + FACTORY_ASSET_ID: "fa".repeat(32), + LENDER_NFT_ASSET_ID: "1e".repeat(32), + LENDING_PROGRAM_ID: "a9b4ade7", + LOAN_EXPIRATION_TIME: "900000", + PRINCIPAL_AMOUNT: "100000", + PRINCIPAL_ASSET_ID: "d0".repeat(32), + PRINCIPAL_INTEREST_RATE: "1000", + PROTOCOL_FEE_KEEPER_ASSET_ID: "fe".repeat(32), +}; + +const DEX = { + AMOUNT_B: "250000", + ASSET_B: "bb".repeat(32), + OFFER_AMOUNT: "500000", + OFFER_ASSET_ID: "aa".repeat(32), +}; + +/** What each asset still needs from the wallet, which is the whole question funding asks. */ +function outstanding( + manifest: Record<string, unknown>, + name: string, + scope: ReferenceScope, + held: HeldValue[], +): Record<string, string> { + const action = findAction(normaliseManifest(manifest).manifest, name); + + if (!action) { + throw new Error(`this document declares no ${name}`); + } + + const plan = planAction(action, scope); + + if (!plan.ok) { + throw new Error(plan.reason); + } + + const ledger = assetLedger(action, plan.plan.outputs, { + held, + policyAsset: POLICY_ASSET, + scope, + }); + + if (!ledger.ok) { + throw new Error(ledger.reason); + } + + return Object.fromEntries( + ledger.ledger.entries.map((entry) => [entry.asset, (entry.needed - entry.held).toString()]), + ); +} + +// Every one of these is the document as its authors published it, read with a deployment +// filled in. The claim is not that the wallet can build them — three of them state a position +// it cannot put a funded input at — but that what each asset costs is now a separate figure, +// and that the figure is right. +describe("what the published corpus actually asks a wallet to fund", () => { + const lendingScope = (extra: Record<string, unknown> = {}): ReferenceScope => ({ + fee: 0n, + instance: LENDING, + params: { LENDER_VAULT_AMOUNT: "105000", TOTAL_PROTOCOL_FEE: "5000", ...extra }, + }); + + // A swap: the taker finds the asset being paid and the fee, and nothing else. The offered + // asset comes out of the covenant and goes straight back out, so it nets to nothing. + test("a two-asset swap is funded in the asset it pays and in the fee, and in nothing else", () => { + expect( + outstanding( + dexManifest as unknown as Record<string, unknown>, + "Settle", + { + fee: 0n, + inputs: { offer_in: { amount_sat: 500_000n } }, + instance: DEX, + params: {}, + }, + [{ asset: DEX.OFFER_ASSET_ID, id: "offer_in", sats: 500_000n }], + ), + ).toEqual({ + [DEX.ASSET_B]: "250000", + [DEX.OFFER_ASSET_ID]: "0", + [POLICY_ASSET]: "0", + }); + }); + + // Accepting an offer: the lender finds the principal. The collateral is the covenant's and + // stays in it, the NFT it takes is the covenant's too, and the fee is the wallet's. + test("accepting an offer needs the principal and nothing else", () => { + expect( + outstanding( + lendingManifest as unknown as Record<string, unknown>, + "AcceptOffer", + lendingScope(), + [ + { asset: LENDING.COLLATERAL_ASSET_ID, id: "pending_offer_in", sats: 500_000n }, + { asset: LENDING.LENDER_NFT_ASSET_ID, id: "lender_nft_in", sats: 1n }, + ], + ), + ).toEqual({ + [LENDING.COLLATERAL_ASSET_ID]: "0", + [LENDING.LENDER_NFT_ASSET_ID]: "0", + [LENDING.PRINCIPAL_ASSET_ID]: "100000", + [POLICY_ASSET]: "0", + }); + }); + + // Claiming the principal needs nothing beyond the fee: the principal is in the covenant + // being spent, and the NFT that authorises the claim comes back out again. + test("claiming the principal needs nothing but the fee, and the borrower's own token", () => { + expect( + outstanding( + lendingManifest as unknown as Record<string, unknown>, + "ClaimPrincipal", + lendingScope(), + [{ asset: LENDING.PRINCIPAL_ASSET_ID, id: "principal_asset_auth_in", sats: 100_000n }], + ), + ).toEqual({ + [LENDING.BORROWER_NFT_ASSET_ID]: "1", + [LENDING.PRINCIPAL_ASSET_ID]: "0", + [POLICY_ASSET]: "0", + }); + }); + + // Cancelling burns two tokens by paying them to an unspendable output. A runtime reading an + // OP_RETURN as paying nothing would have funded neither, and the token the wallet holds + // would never have been added as an input at all. + test("cancelling an offer funds the token it burns", () => { + expect( + outstanding( + lendingManifest as unknown as Record<string, unknown>, + "CancelOffer", + lendingScope(), + [ + { asset: LENDING.COLLATERAL_ASSET_ID, id: "pending_offer_in", sats: 500_000n }, + { asset: LENDING.LENDER_NFT_ASSET_ID, id: "lender_nft_in", sats: 1n }, + ], + ), + ).toEqual({ + [LENDING.BORROWER_NFT_ASSET_ID]: "1", + [LENDING.COLLATERAL_ASSET_ID]: "0", + [LENDING.LENDER_NFT_ASSET_ID]: "0", + [POLICY_ASSET]: "0", + }); + }); + + // Repaying: the debt is the borrower's to find, in the principal asset, and the collateral + // comes back out of the covenant it was locked in. + test("repaying a loan needs the debt in the principal asset", () => { + expect( + outstanding( + lendingManifest as unknown as Record<string, unknown>, + "RepayLoan", + lendingScope(), + [{ asset: LENDING.COLLATERAL_ASSET_ID, id: "active_offer_in", sats: 500_000n }], + ), + ).toEqual({ + [LENDING.BORROWER_NFT_ASSET_ID]: "1", + [LENDING.COLLATERAL_ASSET_ID]: "0", + [LENDING.PRINCIPAL_ASSET_ID]: "110000", + [POLICY_ASSET]: "0", + }); + }); + + // This action used to be the one here that could not be read at all, and not for anything to + // do with its assets: the record it publishes declares three part types the object-form + // vocabulary had no word for. It was asserted as a refusal rather than skipped, so that + // whoever added them would find this case waiting to be turned on. It is on. + // + // Creating an offer: the wallet brings the collateral and the factory's auth token, and mints + // the two tokens the offer's two sides are held by. The factory reappears at both ends, which + // is why one unit of it is still outstanding after the one the wallet holds. + test("creating an offer needs the collateral, the factory, and the tokens it mints", () => { + expect( + outstanding( + lendingManifest as unknown as Record<string, unknown>, + "CreateOffer", + lendingScope(), + [ + { asset: LENDING.COLLATERAL_ASSET_ID, id: "collateral_in", sats: 500_000n }, + { asset: LENDING.FACTORY_ASSET_ID, id: "factory_auth_in", sats: 1n }, + ], + ), + ).toEqual({ + [LENDING.BORROWER_NFT_ASSET_ID]: "1", + [LENDING.COLLATERAL_ASSET_ID]: "0", + [LENDING.FACTORY_ASSET_ID]: "1", + [LENDING.LENDER_NFT_ASSET_ID]: "1", + [POLICY_ASSET]: "0", + }); + }); + + // And where each asset's surplus goes back to, which is what makes the change for one of + // them separate from the change for another. + test("and each asset that declares a change output keeps its own", () => { + const action = findAction( + normaliseManifest(lendingManifest as unknown as Record<string, unknown>).manifest, + "AcceptOffer", + ); + const scope = lendingScope(); + const plan = action && planAction(action, scope); + + if (!action || !plan?.ok) { + throw new Error("the published AcceptOffer could not be planned"); + } + + const ledger = assetLedger(action, plan.plan.outputs, { + held: [], + policyAsset: POLICY_ASSET, + scope, + }); + + if (!ledger.ok) { + throw new Error(ledger.reason); + } + + expect( + Object.fromEntries( + ledger.ledger.entries + .filter((entry) => entry.change) + .map((entry) => [entry.asset, entry.change?.id]), + ), + ).toEqual({ + [LENDING.PRINCIPAL_ASSET_ID]: "principal_change", + [POLICY_ASSET]: "fee_change", + }); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/assetLedger.ts b/packages/tx-manifest/src/evaluation/assetLedger.ts new file mode 100644 index 0000000..a2b96fc --- /dev/null +++ b/packages/tx-manifest/src/evaluation/assetLedger.ts @@ -0,0 +1,291 @@ +import { statedAsset } from "../document/asset"; +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; +import type { PlannedOutput } from "./plan"; + +/** + * What one asset costs this transaction, and what the transaction already brings in it. + * + * One of these per asset, rather than one number for the whole transaction. A single running + * total is only sound while there is a single asset: added together, three units of a + * one-of-a-kind token and three thousand base units of money make six of nothing, and a wallet + * that funds six of nothing is a wallet that funds neither. + */ +export type AssetEntry = { + /** The asset id, as the chain writes it. */ + asset: string; + /** + * The declared output this asset's surplus returns to, when the document declares one. + * + * Only the asset the network charges its fees in can be left to the signing module, because + * only that one has a fee taken out of it and therefore an amount nobody knows until the + * transaction has been weighed. Every other asset's change is an exact figure, and an exact + * figure needs an output to land in. + */ + change?: { blinded: boolean; id: string }; + /** + * Base units this transaction already brings in this asset before the wallet adds any of + * its own: what the covenants it spends hold, and what its issuances create. + */ + held: bigint; + /** Base units the action's outputs pay in this asset. Change is not counted; it has no amount. */ + needed: bigint; +}; + +/** Which asset each piece of an action is in, and what each of those assets needs. */ +export type AssetLedger = { + /** Every asset this action moves, in the order a person reading the document meets it. */ + entries: AssetEntry[]; + /** The asset of each planned output, in the plan's own order. */ + outputs: string[]; + /** Every input the wallet has to find for itself, in the order the action declares them. */ + walletInputs: { asset: string; id: string }[]; +}; + +export type AssetLedgerResult = + | { ok: false; reason: string; reject: "document-fault" | "foreign-asset" } + | { ok: true; ledger: AssetLedger }; + +/** What this transaction brings in an asset without the wallet spending anything of its own. */ +export type HeldValue = { + asset: string; + /** + * Whether the transaction creates these units rather than finding them at an outpoint. + * + * One input can bring both: a covenant holding one asset, spent on the path that mints + * another, arrives here twice under one id. Both are really in the transaction and both are + * counted — but only the first is what the input *spends*, and it is the only one the + * document's word about that input can be checked against. + */ + created?: true; + /** The input this value arrives on, so a disagreement can name it. */ + id: string; + sats: bigint; +}; + +type Context = { + notes?: NormalisationNote[]; + policyAsset: string; + scope: ReferenceScope; +}; + +export type AssetResolution = { ok: false; reason: string } | { ok: true; id: string }; + +/** + * Which asset a declared `asset` field is, once the deployment and the request have been read. + * + * The corpus states an asset as a lookup far more often than as an id — every asset in every + * published protocol, in fact — so this is where most of them first become a thing rather than + * a spelling. A site that states none is stating the asset the network charges fees in: that is + * the only asset a document can leave unsaid and still be understood by everyone reading it. + */ +export function resolveAsset(declared: unknown, at: string, context: Context): AssetResolution { + if (declared === undefined) { + return { id: context.policyAsset.trim().toLowerCase(), ok: true }; + } + + if (typeof declared !== "string") { + return { ok: false, reason: `The asset at ${at} is not written as text.` }; + } + + const stated = statedAsset(declared, context.policyAsset); + + if (stated.kind === "network") { + return { id: context.policyAsset.trim().toLowerCase(), ok: true }; + } + + if (stated.kind === "identified") { + return { id: stated.id, ok: true }; + } + + const found = resolveReference(stated.reference, "asset", context.scope, context.notes); + + if (!found.ok) { + return { + ok: false, + reason: + `The asset at ${at} is stated as ${stated.reference}, and this wallet could not ` + + `establish what that is: ${found.reason}`, + }; + } + + if (typeof found.value !== "string") { + return { + ok: false, + reason: + `The asset at ${at} is stated as ${stated.reference}, which resolved to something ` + + "that is not an asset id.", + }; + } + + const resolved = statedAsset(found.value, context.policyAsset); + + if (resolved.kind === "deferred") { + return { + ok: false, + reason: + `The asset at ${at} is stated as ${stated.reference}, which resolved to ` + + `${found.value} — another lookup rather than an asset.`, + }; + } + + return { + id: resolved.kind === "network" ? context.policyAsset.trim().toLowerCase() : resolved.id, + ok: true, + }; +} + +/** + * Reads one action as a statement about several assets rather than about one amount. + * + * Everything here is a rule of the format: an output pays in the asset it states, an input + * arrives in the asset it states, a covenant holds whatever the chain says it holds, and an + * issuance creates what it declares. Nothing recognises a protocol, a deployment or a name. + * + * The plan is read positionally against the action's own outputs, which is exactly how the plan + * was built — one planned output per declared record, in order. The ids are compared as well, so + * a plan that ever stopped lining up is refused here rather than silently attributing an amount + * to the wrong asset. + */ +export function assetLedger( + action: NormalisedAction, + planned: PlannedOutput[], + context: Context & { held: HeldValue[] }, +): AssetLedgerResult { + const declaredOutputs = asArray(action.node.outputs) + .map((entry) => asRecord(entry)) + .filter((entry) => entry !== undefined); + + if (declaredOutputs.length !== planned.length) { + return { + ok: false, + reason: + `${action.name} plans ${planned.length} outputs against ${declaredOutputs.length} ` + + "declared ones, so this wallet cannot say which asset each one pays in.", + reject: "document-fault", + }; + } + + const entries = new Map<string, AssetEntry>(); + const entryFor = (asset: string): AssetEntry => { + const existing = entries.get(asset); + + if (existing) { + return existing; + } + + const created: AssetEntry = { asset, held: 0n, needed: 0n }; + + entries.set(asset, created); + + return created; + }; + + // The asset the network charges its fees in is always part of the reckoning, whether or not + // the action mentions it: the fee is paid in it and the wallet pays the fee. + entryFor(context.policyAsset.trim().toLowerCase()); + + const walletInputs: { asset: string; id: string }[] = []; + const outputs: string[] = []; + // Only what the chain reports, keyed by the input it arrived on. An input that issues an + // asset also reports one here, under the same id — and letting that win turns the check + // below into a comparison of the document's word against the asset this very input just + // created, which disagree for every covenant-sourced issuance and should. + const heldById = new Map( + context.held.filter((value) => value.created !== true).map((value) => [value.id, value]), + ); + + for (const entry of asArray(action.node.inputs)) { + const declared = asRecord(entry); + + if (!declared) { + continue; + } + + const id = typeof declared.id === "string" ? declared.id : "(unnamed)"; + const resolved = resolveAsset(declared.asset, `input ${id}`, context); + + if (!resolved.ok) { + return { ok: false, reason: resolved.reason, reject: "foreign-asset" }; + } + + entryFor(resolved.id); + + if (typeof asRecord(declared.utxo_source)?.utxo_type === "string") { + // A covenant input's asset is whatever the chain says is at that outpoint. The document + // states one too, and the two disagreeing means the covenant is not holding what the + // action says it holds — which would fund the stated asset and strand the real one. + const held = heldById.get(id); + + if (declared.asset !== undefined && held && held.asset !== resolved.id) { + return { + ok: false, + reason: + `${action.name} says input ${id} is in ${resolved.id}, and the output it spends ` + + `holds ${held.asset}.`, + reject: "foreign-asset", + }; + } + + continue; + } + + walletInputs.push({ asset: resolved.id, id }); + } + + for (const [at, declared] of declaredOutputs.entries()) { + const output = planned[at]; + + if (!output) { + continue; + } + + const id = typeof declared.id === "string" ? declared.id : ""; + + if (id !== output.id) { + return { + ok: false, + reason: + `${action.name} declares ${id || "(unnamed)"} where its plan has ` + + `${output.id || "(unnamed)"}, so this wallet cannot say which asset that output ` + + "pays in.", + reject: "document-fault", + }; + } + + const resolved = resolveAsset(declared.asset, `output ${id || "(unnamed)"}`, context); + + if (!resolved.ok) { + return { ok: false, reason: resolved.reason, reject: "foreign-asset" }; + } + + const entry = entryFor(resolved.id); + + outputs.push(resolved.id); + + if (output.target.kind === "change") { + // The first one wins. A document declaring two change outputs for one asset is + // declaring one place for its surplus twice, and splitting a surplus between them + // would be the wallet deciding something the document did not say. + entry.change ??= { blinded: output.blinding.blinding === "hidden", id }; + + continue; + } + + entry.needed += output.sats ?? 0n; + } + + for (const value of context.held) { + entryFor(value.asset).held += value.sats; + } + + return { + ledger: { + entries: [...entries.values()], + outputs, + walletInputs, + }, + ok: true, + }; +} diff --git a/packages/tx-manifest/src/evaluation/blinding.test.ts b/packages/tx-manifest/src/evaluation/blinding.test.ts index bd224c2..3eb8280 100644 --- a/packages/tx-manifest/src/evaluation/blinding.test.ts +++ b/packages/tx-manifest/src/evaluation/blinding.test.ts @@ -15,6 +15,17 @@ const CORPUS = { lending_v3: lendingV3, } as unknown as Record<string, Record<string, unknown>>; +/** The actions that declare no change output at all, which the module appends one for anyway. */ +function actionsWithoutChange(document: Record<string, unknown>) { + const { manifest } = normaliseManifest(document); + + return manifest.actions + .filter((action) => + asArray(action.node.outputs).every((entry) => asRecord(entry)?.destination !== "change"), + ) + .map((action) => action.name); +} + describe("the order the format resolves blinding in", () => { test("the output's own word comes first, over the document's", () => { expect(resolveBlinding({ declared: false, documentDefault: true })).toEqual({ @@ -54,31 +65,92 @@ describe("the order the format resolves blinding in", () => { }); }); -describe("what the published protocols resolve to", () => { - /** Every output the corpus declares, with the destination that decides whether it can hide. */ - function outputsOf(document: Record<string, unknown>) { - const { manifest } = normaliseManifest(document); - const found: { declared: unknown; unblindable?: "covenant" | "data" }[] = []; - - for (const action of manifest.actions) { - for (const entry of asArray(action.node.outputs)) { - const output = asRecord(entry); - const destination = asRecord(output?.destination); - - found.push({ - declared: output?.confidential, - ...(typeof destination?.utxo_type === "string" - ? { unblindable: "covenant" as const } - : destination?.type === "op_return" - ? { unblindable: "data" as const } - : {}), - }); - } - } +/** + * The one deviation, and what it costs. + * + * A contract action can be funded only by outputs that hide nothing: unblinding one needs the + * secrets that go with it, and the signing module is handed an outpoint and its bytes and + * nothing more. So change returned hidden is money the next action cannot reach, and a sequence + * of actions starves itself after the first. + * + * The wallet publishes it instead. That is against the format, which says silence about + * confidentiality is itself a decision and that this network's decision is to hide, and the + * change amount is on the chain as a result. The word that was set aside is carried out of here + * so a person can be told which one it was. + */ +describe("what this wallet does with a contract action's own change", () => { + test("publishes it, over the network's default that would have hidden it", () => { + expect(resolveBlinding({ change: true })).toEqual({ + blinding: "open", + decidedBy: "spendable-change", + overrode: "chain", + }); + }); + + test("and over the document's own default, carrying that word instead", () => { + expect(resolveBlinding({ change: true, documentDefault: true })).toEqual({ + blinding: "open", + decidedBy: "spendable-change", + overrode: "document", + }); + }); + + // The case a person is owed the most: the protocol asked for this outright and the wallet + // published it anyway, because honouring the request would have stranded their money. + test("and over the protocol asking for it outright, carrying that word instead", () => { + expect(resolveBlinding({ change: true, declared: true })).toEqual({ + blinding: "open", + decidedBy: "spendable-change", + overrode: "output", + }); + }); + + // Only where the format would have hidden. A protocol asking for open change is agreed + // with, and nothing was overridden, so nothing claims to have been. + test("but overrides nothing when the protocol asked for open change itself", () => { + expect(resolveBlinding({ change: true, declared: false })).toEqual({ + blinding: "open", + decidedBy: "output", + }); + }); + + // The deviation is exactly this wide. An output paid to the wallet is not change, however + // much it looks like money coming back, and it is left hidden where the format hides it. + test("and reaches nothing that is not change", () => { + expect(resolveBlinding({})).toEqual({ blinding: "hidden", decidedBy: "chain" }); + expect(resolveBlinding({ declared: true })).toEqual({ + blinding: "hidden", + decidedBy: "output", + }); + }); +}); + +/** Every output the corpus declares, with the destination that decides whether it can hide. */ +function outputsOf(document: Record<string, unknown>) { + const { manifest } = normaliseManifest(document); + const found: { change?: boolean; declared: unknown; unblindable?: "covenant" | "data" }[] = []; - return found; + for (const action of manifest.actions) { + for (const entry of asArray(action.node.outputs)) { + const output = asRecord(entry); + const destination = asRecord(output?.destination); + + found.push({ + declared: output?.confidential, + ...(output?.destination === "change" ? { change: true } : {}), + ...(typeof destination?.utxo_type === "string" + ? { unblindable: "covenant" as const } + : destination?.type === "op_return" + ? { unblindable: "data" as const } + : {}), + }); + } } + return found; +} + +describe("what the published protocols resolve to", () => { // The count this issue turns on. Every output the four protocols declare, by what decided // it — and the number decided by the network's own default is the number this wallet // cannot build the moment anything acts on the answer. @@ -98,12 +170,36 @@ describe("what the published protocols resolve to", () => { // Nothing is ever decided by the document: no published manifest states a file-level // default. Thirty-nine outputs are open because their destination could never hide and - // twenty because the protocol said so — while thirty-eight are hidden purely because - // nobody spoke, and every one of those is an output this wallet cannot build. + // twenty because the protocol said so. + // + // Twenty-nine are change, and not one document in the corpus says a word about any of + // them — so every one of those was hidden by this network's default until the wallet + // began publishing them, and each is an amount now on the chain that the format would + // have kept off it. What is left hidden by silence is nine. expect(counted).toEqual({ - "hidden/chain": 38, + "hidden/chain": 9, "open/output": 20, + "open/spendable-change": 29, "open/unblindable": 39, }); }); + + /** + * The silence that used to get two answers. + * + * A declared change output saying nothing resolved hidden; an action declaring no change + * output at all had nothing to resolve and fell through to open. The signing module appends + * change either way, so one absence of a word built two opposite outputs. These three + * actions, across two of the four protocols, are where that was reachable. + * + * Both cases are a contract action's own change, so both are published now. The + * inconsistency did not have to be decided; it stopped existing. + */ + test("and the three actions that declare no change output, which the module appends one for", () => { + expect( + Object.keys(CORPUS).flatMap((name) => + actionsWithoutChange(CORPUS[name]!).map((action) => `${name}.${action}`), + ), + ).toEqual(["last_will.ColdBreak", "last_will.Refresh", "lending_v2.PrepareLender"]); + }); }); diff --git a/packages/tx-manifest/src/evaluation/blinding.ts b/packages/tx-manifest/src/evaluation/blinding.ts index 4190645..6bb8bca 100644 --- a/packages/tx-manifest/src/evaluation/blinding.ts +++ b/packages/tx-manifest/src/evaluation/blinding.ts @@ -6,11 +6,24 @@ * which makes silence a decision rather than an absence — and a runtime that read the first * two and stopped would build an open output for every document that says nothing, which is * every document in the published corpus. + * + * One destination is answered against that order rather than by it, and it is the only one: + * a contract action's own change. See `resolveBlinding` for what that costs and why it was + * chosen anyway. */ /** What an output does with the value it carries. */ export type Blinding = "hidden" | "open"; +/** + * Whose word decided an output's blinding, or which rule answered instead of a word. + * + * The first three are the format's precedence. `unblindable` is a destination that could + * never hide whatever anyone says. `spendable-change` is this wallet's own rule, and it is + * the one place the wallet answers over the format rather than under it. + */ +export type BlindingWord = "chain" | "document" | "output" | "spendable-change" | "unblindable"; + /** * Where an output's blinding was decided, so a refusal can say whose word it was. * @@ -20,7 +33,15 @@ export type Blinding = "hidden" | "open"; */ export type BlindingDecision = { blinding: Blinding; - decidedBy: "chain" | "document" | "output" | "unblindable"; + decidedBy: BlindingWord; + /** + * The word this wallet set aside, present only where it overrode the format. + * + * Carried rather than dropped because publishing an amount the protocol asked to hide and + * publishing one nobody spoke about are the same output and not the same sentence, and the + * person is owed that difference here for exactly the reason they are owed it above. + */ + overrode?: BlindingWord; }; /** A destination that can never hide what it carries, whatever anything says. */ @@ -33,12 +54,29 @@ export type UnblindableTarget = "covenant" | "data"; * A Simplicity program reads exact amounts and asset ids through jets that cannot introspect a * commitment, so a hidden covenant output is one its own contract could never check; an * OP_RETURN carries bytes rather than value and has nothing to hide. + * + * A contract action's own change is answered after it, and against it. This is a deliberate + * deviation from the format and the only one: the format says an output's silence about + * confidentiality is itself a decision, and that on this network the decision is to hide. The + * wallet keeps that rule everywhere else and breaks it here, so the change amount is published + * on chain where the format would have kept it. That is the price and it was accepted knowingly. + * + * What it buys is that the money comes back spendable. A contract action can be funded only by + * outputs that hide nothing — unblinding one needs the secrets that go with it, and the signing + * module is handed an outpoint and its bytes and nothing more — so change returned hidden is + * money the next action cannot reach, and a sequence of actions starves itself after the first. + * + * The deviation is exactly this wide: change, and nothing else. It fires only where the format + * would have hidden, so a protocol that asks for its change in the open is simply agreed with, + * and it never touches an output that pays anywhere but back to this person. */ export function resolveBlinding(input: { - /** The document's file-level default, when it states one. */ - documentDefault?: unknown; + /** Set when this output is the action's own change, which the wallet returns spendable. */ + change?: boolean; /** The output's own declaration, when it states one. */ declared?: unknown; + /** The document's file-level default, when it states one. */ + documentDefault?: unknown; /** Set when the destination cannot hide anything whatever the document says. */ unblindable?: UnblindableTarget; }): BlindingDecision { @@ -46,6 +84,19 @@ export function resolveBlinding(input: { return { blinding: "open", decidedBy: "unblindable" }; } + const format = byPrecedence(input); + + // Only where the format would have hidden. Where it already answers open there is nothing + // to override and no deviation to declare — the protocol and this wallet agree. + if (input.change && format.blinding === "hidden") { + return { blinding: "open", decidedBy: "spendable-change", overrode: format.decidedBy }; + } + + return format; +} + +/** The order the format itself defines, with nothing of this wallet's in it. */ +function byPrecedence(input: { declared?: unknown; documentDefault?: unknown }): BlindingDecision { if (typeof input.declared === "boolean") { return { blinding: input.declared ? "hidden" : "open", decidedBy: "output" }; } @@ -62,9 +113,44 @@ export function resolveBlinding(input: { /** How a refusal says whose word made this output one the wallet cannot build. */ export function describeBlinding(decision: BlindingDecision): string { - return decision.decidedBy === "output" - ? "this protocol asks for it to be hidden" - : decision.decidedBy === "document" - ? "this protocol hides its outputs by default" - : "nothing says otherwise and this network hides an output by default"; + return sentenceFor(decision.decidedBy); +} + +/** + * What a person is told when this wallet published a contract's change. + * + * It leads with the word that was set aside, in that word's own sentence, and then says + * plainly that the wallet published the amount anyway and what that bought. Anything shorter + * would let the wallet override a protocol in exactly the place the person was told to trust + * the wallet's reading of it. + */ +export function describePublishedChange(overrode?: BlindingWord): string { + return overrode === undefined + ? sentenceFor("spendable-change") + : `${sentenceFor(overrode)}, and this wallet publishes it anyway so your next action can spend it`; +} + +/** The one sentence each word gets, so a word cannot be described two ways in two places. */ +function sentenceFor(word: BlindingWord): string { + switch (word) { + case "document": { + return "this protocol hides its outputs by default"; + } + + case "output": { + return "this protocol asks for it to be hidden"; + } + + case "spendable-change": { + return "this wallet publishes a contract's change so your next action can spend it"; + } + + case "unblindable": { + return "this output's own contract has to read the amount"; + } + + default: { + return "nothing says otherwise and this network hides an output by default"; + } + } } diff --git a/packages/tx-manifest/src/evaluation/computedValue.test.ts b/packages/tx-manifest/src/evaluation/computedValue.test.ts new file mode 100644 index 0000000..cdbb7c6 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/computedValue.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; + +import { computedValue, computesValue } from "./computedValue"; + +// The whole of this file is about one distinction: a value the document computes against a +// value the document states. Several positions in the format accept both in the same slot, and +// the corpus writes both, so reading one as the other is not a type error anywhere — it is a +// different number in a transaction. + +describe("what counts as a value the document computes", () => { + test("arithmetic does", () => { + expect(computesValue("params.A + 1")).toBe(true); + expect(computesValue("A * B / 10000")).toBe(true); + expect(computesValue("pow(2, 8)")).toBe(true); + expect(computesValue("-1")).toBe(true); + }); + + test("a reference does not", () => { + expect(computesValue("$params.PRINCIPAL_AMOUNT")).toBe(false); + expect(computesValue("instance.CURRENT_DEBT")).toBe(false); + expect(computesValue("BORROWER_NFT_ASSET_ID")).toBe(false); + }); + + // The two literals the corpus writes at a deployment's fields, and the reason the question + // is asked by looking for operators rather than by trying to evaluate. Both of these are + // legal arithmetic: read as a formula the first is unchanged and the second becomes "0", + // which is a different value at every position that encodes it and an error at none. + test("a literal does not, including the ones that evaluate to something else", () => { + expect(computesValue("2")).toBe(false); + expect(computesValue("0".repeat(64))).toBe(false); + expect(computedValue("0".repeat(64), "compileParam", { params: {} })).toEqual({ + ok: true, + value: "0", + }); + }); +}); + +describe("what a computed value comes to", () => { + test("is the number, as the string every position records a value as", () => { + expect(computedValue("params.A * 3", "compileParam", { params: { A: "14" } })).toEqual({ + ok: true, + value: "42", + }); + }); + + // The position decides what a term may name, and a computed value is not a way round that. + // The fee is refused here because a value at this position decides a covenant's address and + // the fee comes from the transaction that pays to it. + test("refuses a term the position does not admit, as a position error", () => { + const found = computedValue("fee + 1", "compileParam", { fee: 100n, params: {} }); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("cannot be used as a compile parameter"); + }); + + test("refuses arithmetic that leaves the range these numbers are held in", () => { + const found = computedValue("pow(2, 62) * 4", "compileParam", { params: {} }); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("64-bit range"); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/computedValue.ts b/packages/tx-manifest/src/evaluation/computedValue.ts new file mode 100644 index 0000000..a957df6 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/computedValue.ts @@ -0,0 +1,58 @@ +import type { NormalisationNote } from "../document/normalise"; +import type { ReferenceScope, ReferenceSiteKind } from "../document/references"; +import { evaluateExpression } from "./evaluate"; + +/** + * A value the document works out for itself, wherever the document writes one. + * + * The format lets a protocol state arithmetic in place of a value at more than one position — + * a parameter's `compute`, an issued amount, an output's amount, a field of the deployment an + * action creates. They are the same construct and they are read the same way, through + * `evaluate.ts`. What this file adds is the one question `evaluate.ts` cannot answer for a + * caller: whether the text in front of it is arithmetic at all. + * + * **That question is not cosmetic, and getting it wrong loses a value silently.** Several + * positions accept a literal and a formula in the same slot, and the corpus writes both — a + * field holding `"2"` is a field holding two, and a field holding thirty-two zero bytes is an + * all-zero hash. Both of those are also legal arithmetic: evaluated, the first is unchanged + * and the second becomes `"0"`, which is a different value at every position that encodes it. + * Nothing downstream could tell, because `0` is a perfectly good number. + * + * So arithmetic is recognised by the operators it is written with rather than by whether it + * evaluates. A reference, a decimal literal and a hex literal contain none of the characters + * below; every expression that is more than a single term contains at least one. That is + * exact for this grammar rather than a heuristic, and it is the reason a literal survives. + */ + +/** Every character the expression grammar uses and no single value or name can contain. */ +const OPERATORS = new Set(["%", "(", ")", "*", "+", ",", "-", "/"]); + +/** + * Whether this text computes a value rather than being one or naming one. + * + * Ask it only after a reference has been tried and failed. A reference never contains one of + * these characters, so the order does not change any answer today; it is the order that keeps + * the two readings separable if the reference syntax ever grows one. + */ +export function computesValue(text: string): boolean { + return [...text].some((character) => OPERATORS.has(character)); +} + +/** + * What one computed value comes to, as the string every position records a value as. + * + * The site is the caller's, not this file's. A term is legal where the position says it is + * legal — a formula at a position that cannot see the fee is refused for naming the fee + * rather than for arithmetic — and passing the position through is what keeps that true of a + * computed value as much as of a plain reference. + */ +export function computedValue( + text: string, + site: ReferenceSiteKind, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: string } { + const evaluated = evaluateExpression(text, site, scope, notes); + + return evaluated.ok ? { ok: true, value: String(evaluated.value) } : evaluated; +} diff --git a/packages/tx-manifest/src/evaluation/contractArithmetic.test.ts b/packages/tx-manifest/src/evaluation/contractArithmetic.test.ts new file mode 100644 index 0000000..1dd5134 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/contractArithmetic.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test"; + +import { evaluateExpression } from "./evaluate"; + +/** + * The wallet's arithmetic against the covenant's, for the formulas a live protocol writes. + * + * The format defines no arithmetic. Overflow, division by zero, associativity and truncation + * are all undefined in the specification, so nothing in a document says which reading is meant + * and two runtimes can disagree without either being wrong on paper. What settles it is the + * contract: it is the thing that will accept or reject the transaction, after it is signed, for + * a reason nobody watching could predict. + * + * So the contract's own arithmetic is transcribed below from the published SimplicityHL source + * and the wallet is compared against it, rather than both being compared against a description. + * + * From `lending.simf` in the deployed protocol's contract crate: + * + * ``` + * fn mul_div(x: u64, y: u64, denominator: u64) -> u64 { + * let product: u128 = jet::multiply_64(x, y); + * let (hi, lo): (u64, u64) = <u128>::into(product); + * check_asset_amounts_eq(hi, 0); + * jet::divide_64(lo, denominator) + * } + * fn get_max_basis_points() -> u64 { 10_000 } + * fn apply_basis_points(amount: u64, bps: u64) -> u64 { mul_div(amount, bps, get_max_basis_points()) } + * fn get_protocol_fee_bps() -> u64 { 1_000 } + * fn get_protocol_fee_amount(fee_amount: u64) -> u64 { apply_basis_points(fee_amount, get_protocol_fee_bps()) } + * fn get_total_fee_amount() -> u64 { apply_basis_points(param::PRINCIPAL_AMOUNT, param::PRINCIPAL_INTEREST_RATE) } + * fn get_total_amount_to_repay() -> u64 { safe_add_64(param::PRINCIPAL_AMOUNT, get_total_fee_amount()) } + * ``` + * + * `safe_add_64` asserts no carry out of 64 bits; `mul_div` asserts the product's high word is + * zero. Both are unsigned 64-bit. That is the one place the two runtimes are not identical, and + * the direction of the difference is what makes it safe — see the last case below. + */ + +const U64 = 2n ** 64n; + +class Rejected extends Error {} + +/** `mul_div`, including its own assertion that the product fits in 64 bits unsigned. */ +function mulDiv(x: bigint, y: bigint, denominator: bigint): bigint { + const product = x * y; + + if (product >= U64) { + throw new Rejected("the product's high word is not zero"); + } + + return product / denominator; +} + +const applyBasisPoints = (amount: bigint, bps: bigint) => mulDiv(amount, bps, 10_000n); + +function contractDebt(principal: bigint, rate: bigint): bigint { + const sum = principal + applyBasisPoints(principal, rate); + + if (sum >= U64) { + throw new Rejected("the sum carries out of 64 bits"); + } + + return sum; +} + +const contractProtocolFee = (principal: bigint, rate: bigint) => + applyBasisPoints(applyBasisPoints(principal, rate), 1_000n); + +/** The three formulas the deployed document writes, character for character. */ +const DEBT = + "params.PRINCIPAL_AMOUNT + params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000"; +const PROTOCOL_FEE = + "instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000"; +const LENDER_VAULT = + "instance.CURRENT_DEBT - instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000"; + +function wallet(text: string, values: Record<string, string>): bigint | string { + const found = evaluateExpression(text, "compileParam", { instance: values, params: values }); + + return found.ok ? found.value : found.reason; +} + +const amounts = (principal: bigint, rate: bigint) => ({ + PRINCIPAL_AMOUNT: String(principal), + PRINCIPAL_INTEREST_RATE: String(rate), +}); + +/** + * Inputs chosen so truncation decides the answer rather than merely occurring. + * + * Three of these produce a fee below one unit, where a runtime rounding to nearest or carrying + * a fraction between the two divisions would differ from the contract by a whole unit. + */ +const CASES: [bigint, bigint][] = [ + [50_000n, 500n], + [1n, 1n], + [3n, 3333n], + [7n, 9999n], + [199_999n, 1n], + [1_000_000_007n, 9_999n], + [0n, 5_000n], + [100_000n, 0n], +]; + +describe("the debt a created deployment records", () => { + for (const [principal, rate] of CASES) { + test(`agrees with the contract for ${principal} at ${rate} basis points`, () => { + expect(wallet(DEBT, amounts(principal, rate))).toBe(contractDebt(principal, rate)); + }); + } +}); + +describe("the protocol fee and the lender's share", () => { + for (const [principal, rate] of CASES) { + test(`agree with the contract for ${principal} at ${rate} basis points`, () => { + const fee = contractProtocolFee(principal, rate); + + expect(wallet(PROTOCOL_FEE, amounts(principal, rate))).toBe(fee); + expect( + wallet(LENDER_VAULT, { + ...amounts(principal, rate), + CURRENT_DEBT: String(contractDebt(principal, rate)), + }), + ).toBe(contractDebt(principal, rate) - fee); + }); + } +}); + +/** + * The failure this comparison exists to catch, shown rather than described. + * + * `a * b / c * d / e` is five terms and four operators, and the contract's own nesting — + * `mul_div(mul_div(a, b, c), d, e)` — is exactly one of the ways to group them. Grouped any + * other way the same inputs give a different number, and every one of those numbers is a + * perfectly good `u64` that the covenant would reject after signing. + */ +describe("the grouping the contract's nesting fixes", () => { + const values = amounts(50_000n, 500n); + + test("left to right within one precedence level is the contract's answer", () => { + expect(wallet(PROTOCOL_FEE, values)).toBe(contractProtocolFee(50_000n, 500n)); + expect(wallet(PROTOCOL_FEE, values)).toBe(250n); + }); + + test("every other grouping of the same operators is a different number", () => { + expect( + wallet( + "instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / (10000 * 1000) / 10000", + values, + ), + ).toBe(0n); + expect( + wallet( + "instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / (10000 * 1000 / 10000)", + values, + ), + ).toBe(25_000n); + expect( + wallet( + "instance.PRINCIPAL_AMOUNT * (instance.PRINCIPAL_INTEREST_RATE / 10000) * 1000 / 10000", + values, + ), + ).toBe(0n); + }); +}); + +/** + * Where the two do not agree, and why that direction is the safe one. + * + * The contract works unsigned and admits a product up to 2^64; this runtime holds every + * intermediate in signed 64 bits and admits one up to 2^63. So there is a band of inputs the + * contract would accept and this refuses, and no band where this produces a number the contract + * would reject. A refusal before signing is recoverable; a covenant rejecting a signed + * transaction is not, and says nothing about why. + */ +describe("the range the two do not share", () => { + test("a product past the signed bound refuses here and would have been accepted there", () => { + const values = amounts(2n ** 32n, 2n ** 31n); + + expect(contractProtocolFee(2n ** 32n, 2n ** 31n)).toBe(92_233_720_368_547n); + expect(wallet(PROTOCOL_FEE, values)).toContain("64-bit range"); + }); + + test("a product past the unsigned bound is refused by both", () => { + const values = amounts(2n ** 32n, 2n ** 32n); + + expect(() => contractProtocolFee(2n ** 32n, 2n ** 32n)).toThrow(Rejected); + expect(wallet(PROTOCOL_FEE, values)).toContain("64-bit range"); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/encode.test.ts b/packages/tx-manifest/src/evaluation/encode.test.ts index 33869a0..8ca5a22 100644 --- a/packages/tx-manifest/src/evaluation/encode.test.ts +++ b/packages/tx-manifest/src/evaluation/encode.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { encodeDataParts, encodeLeafItem } from "./encode"; +import type { LeafPartResolver } from "./leafParts"; // The vocabulary comes from the cross-source inventory, which read it out of the reference // implementation's `encode_leaf_bytes`: type is one of u8/u16/u32/u64 or @@ -147,11 +148,131 @@ describe("encodeLeafItem", () => { expect(hex({ type: "u8" })).toContain("value"); }); }); + + /** + * The fourth shape: a kind of leaf carrying a list of parts. + * + * `tapdata` is the only kind this compiler builds — a hidden node hashed as + * `sha256(tag ‖ tag ‖ payload)` with `tag = sha256("TapData")` — so the payload is one run of + * bytes and the parts are that run. The expected bytes below are not this runtime's opinion: + * a live protocol writes these two leaves, its own contract rebuilds them from the flag and + * the debt, and its Rust program builder writes the same thirty-two bytes a third time. + */ + describe("a kind of leaf carrying a payload", () => { + const nothing: LeafPartResolver = (reference) => ({ + ok: false, + reason: `nothing carries "${reference}"`, + }); + + function payload(item: unknown, resolve?: LeafPartResolver) { + const result = encodeLeafItem(item, {}, resolve); + + return result.ok ? result.hex : result.reason; + } + + test("is the bytes of its one part", () => { + expect(payload({ payload: ["0x0102"], type: "tapdata" })).toBe("0102"); + }); + + test("concatenates several parts in the order they are written", () => { + expect(payload({ payload: ["0xaa", { type: "u8", value: 1 }], type: "tapdata" })).toBe( + "aa01", + ); + }); + + /** The flag slot of a live protocol's collateral covenant, active and pending. */ + test("writes a flag as the thirty-two bytes its contract hashes", () => { + expect(payload({ payload: [`0x${"00".repeat(31)}01`], type: "tapdata" })).toBe( + `${"00".repeat(31)}01`, + ); + }); + + /** The debt slot beside it: eight big-endian bytes right-aligned in thirty-two. */ + test("writes a debt big-endian, right-aligned in thirty-two bytes", () => { + expect( + payload({ + payload: [{ align: "right", endian: "be", pad_to: 32, type: "u64", value: 52_500 }], + type: "tapdata", + }), + ).toBe(`${"00".repeat(30)}cd14`); + }); + + test("a part may name something the caller resolves", () => { + expect( + payload( + { + payload: [ + { align: "right", endian: "be", pad_to: 32, type: "u64", value: "instance.DEBT" }, + ], + type: "tapdata", + }, + () => ({ ok: true, value: "52500" }), + ), + ).toBe(`${"00".repeat(30)}cd14`); + }); + + test("and a whole part may be the name, resolving to the bytes it stands for", () => { + expect( + payload({ payload: ["instance.TAG"], type: "tapdata" }, () => ({ + ok: true, + value: "0xabcd", + })), + ).toBe("abcd"); + }); + + /** + * Which of the two mistakes this makes. Text the reference grammar accepts is looked up + * and refused when nothing carries it, so it can never become bytes that resemble a value; + * text it rejects is a literal, which `0x` in front settles for good. + */ + test("a part opening with a digit is a literal and is never looked up", () => { + expect(payload({ payload: ["0011"], type: "tapdata" }, nothing)).toBe("0011"); + }); + + test("a part that names something nothing carries refuses, naming the text", () => { + expect(payload({ payload: ["MISSING"], type: "tapdata" }, nothing)).toContain("MISSING"); + }); + + test("and refuses rather than guessing when there is nothing to resolve against", () => { + expect(payload({ payload: ["MISSING"], type: "tapdata" })).toContain("resolve"); + }); + + test("refuses a kind of leaf this compiler cannot build", () => { + expect(payload({ payload: ["0x00"], type: "tapscript" })).toContain("tapscript"); + }); + + test("refuses a leaf declaring a kind and saying nothing", () => { + expect(payload({ type: "tapdata" })).toContain("payload"); + }); + + test("refuses a payload that is not a list of parts", () => { + expect(payload({ payload: "0x00", type: "tapdata" })).toContain("list of parts"); + }); + + test("refuses an empty payload rather than hashing no bytes", () => { + expect(payload({ payload: [], type: "tapdata" })).toContain("empty"); + }); + + test("refuses a part carrying a payload of its own", () => { + expect( + payload({ payload: [{ payload: ["0x00"], type: "tapdata" }], type: "tapdata" }), + ).toContain("a part is a value"); + }); + + test("names the part it could not encode, because position is the only name one has", () => { + expect(payload({ payload: ["0x00", { type: "u128", value: 1 }], type: "tapdata" })).toContain( + "payload part 2", + ); + }); + }); }); -// The second vocabulary, on an output's `data`. Distinct from the first: no endian, no -// padding, and only three types. Written separately rather than folded into the first, -// because a shared encoder would silently accept `endian` here where the format has none. +// The second vocabulary, on an output's `data`, still reachable under the name the runtime +// calls it by. It is distinct from the first — a different set of types, the opposite integer +// default and no padding at all — and it now lives in `metadataParts.ts`, where each entry +// records what was measured. `metadataParts.test.ts` covers it; what is left here is that the +// old name still answers, and the two things this block used to assert about the vocabulary +// that turned out to be true of the other one. describe("encodeDataParts", () => { function parts(value: unknown): string { const result = encodeDataParts(value); @@ -170,16 +291,25 @@ describe("encodeDataParts", () => { ).toBe("01abcd"); }); - test("u64 is eight bytes, big-endian, as this vocabulary has no endian to choose", () => { - expect(parts({ parts: [{ type: "u64", value: 1 }] })).toBe("0000000000000001"); + // This asserted the reverse, on the reasoning that a hand-written layout reads big-endian and + // that the vocabulary had no key to say otherwise. Both halves were wrong: the key exists and + // a published document writes it, and a deployed reader of these bytes takes every integer + // width here little-endian. + test("u64 is eight bytes, little-endian, which is this vocabulary's default", () => { + expect(parts({ parts: [{ type: "u64", value: 1 }] })).toBe("0100000000000000"); }); + // This named `u32`, which the vocabulary does have. `bytes32` is the honest example: the + // first vocabulary has it, no document writes it at this position, so it stays refused. test("refuses a type this vocabulary does not have", () => { - expect(parts({ parts: [{ type: "u32", value: 1 }] })).toContain("u32"); + expect(parts({ parts: [{ type: "bytes32", value: `0x${"11".repeat(32)}` }] })).toContain( + "bytes32", + ); }); - test("refuses the first vocabulary's keys, which mean nothing here", () => { - expect(parts({ parts: [{ endian: "be", type: "u8", value: 1 }] })).toContain("endian"); + test("refuses the first vocabulary's padding keys, which mean nothing here", () => { + expect(parts({ parts: [{ pad_to: 4, type: "u8", value: 1 }] })).toContain("pad_to"); + expect(parts({ parts: [{ align: "left", type: "u8", value: 1 }] })).toContain("align"); }); test("refuses data that is not a parts list", () => { diff --git a/packages/tx-manifest/src/evaluation/encode.ts b/packages/tx-manifest/src/evaluation/encode.ts index 8816e5a..1eea71e 100644 --- a/packages/tx-manifest/src/evaluation/encode.ts +++ b/packages/tx-manifest/src/evaluation/encode.ts @@ -1,4 +1,6 @@ -import { asArray, asRecord } from "../document/json"; +import { asRecord } from "../document/json"; +import { declaresLeafKind, encodeLeafPayload, type LeafPartResolver } from "./leafParts"; +import { encodeMetadataParts, type PartResolver } from "./metadataParts"; export type EncodedBytes = { hex: string; ok: true } | { ok: false; reason: string }; @@ -12,10 +14,15 @@ export type EncodedBytes = { hex: string; ok: true } | { ok: false; reason: stri * not recognised, and nothing is inferred from the shape of a value. * * The two vocabularies are genuinely different and are implemented separately rather than - * unified. `extra_leaves` has seven types, an endianness and a padding rule; an output's - * object-form `data` has three types and neither. Folding them together would silently - * accept `endian` in a position where the format has no such key, which is the shape of a - * mistake that changes bytes without changing anything visible. + * unified. `extra_leaves` has seven types, one endianness rule and a padding rule; an output's + * object-form `data` has six types, a different endianness default and no padding at all. + * Folding them together would carry a default across that is the reverse of the one measured + * on the other side, which is the shape of a mistake that changes bytes without changing + * anything visible. + * + * This file keeps `extra_leaves`. The object-form vocabulary lives in `metadataParts.ts`, + * where each of its entries records what was measured; `encodeDataParts` below is the name the + * rest of the runtime already calls it by. */ /** The integer widths `extra_leaves` accepts, in bytes. */ @@ -31,18 +38,25 @@ const LEAF_BYTE_LENGTHS: Record<string, number | undefined> = { /** * Encodes one item of a utxo type's `extra_leaves`. * - * Three shapes: a hex literal, a typed value, and a reference to one of the utxo type's own - * state variables — which resolves to that variable's default as a single byte. + * Four shapes: a hex literal, a typed value, a reference to one of the utxo type's own state + * variables — which resolves to that variable's default as a single byte — and a kind of leaf + * with a list of parts, which `leafParts.ts` reads and which encodes each of its parts back + * through here. * * **Order of operations, stated because it decides addresses.** The value is encoded to its * type's natural width, then the endianness is applied, then padding extends it. Padding * before reversing would turn a right-aligned pad into a left-aligned one, so the order is * not arbitrary; it is also not something any document states, and is recorded as an * uncertainty rather than presented as established. + * + * `resolve` is what lets a value inside a payload name something rather than be something. It + * is used at that position and nowhere else: a leaf written as a bare typed value encodes + * exactly the bytes it did before, whether a resolver is supplied or not. */ export function encodeLeafItem( item: unknown, stateVars: Record<string, unknown> = {}, + resolve?: LeafPartResolver, ): EncodedBytes { if (typeof item === "string") { return fromHex(item); @@ -67,6 +81,15 @@ export function encodeLeafItem( return { ok: false, reason: "An extra leaf declares no type, and none is inferred." }; } + if (declaresLeafKind(type, record)) { + return encodeLeafPayload( + type, + record, + (part) => encodeLeafItem(part, stateVars, resolve), + resolve, + ); + } + if (!("value" in record)) { return { ok: false, reason: `The ${type} extra leaf carries no value.` }; } @@ -85,73 +108,12 @@ export function encodeLeafItem( /** * Encodes an output's object-form `data`. * - * `{parts: [{type, value}, …]}`, concatenated in order. Only `bytes`, `u8` and `u64`, and - * no endianness or padding to choose — so a document carrying one of those keys here is - * refused rather than quietly encoded as if it had said nothing. + * The vocabulary itself is in `metadataParts.ts`. This is the name the runtime already reaches + * for, kept so that a caller asking for an output's bytes does not have to know which of the + * format's two vocabularies answered. */ -export function encodeDataParts( - data: unknown, - resolve: (reference: string) => { ok: true; value: unknown } | { ok: false; reason: string } = ( - reference, - ) => ({ ok: true, value: reference }), -): EncodedBytes { - const declared = asRecord(data)?.parts; - - if (!Array.isArray(declared)) { - return { ok: false, reason: "Object-form data carries no parts list." }; - } - - let hex = ""; - - for (const entry of asArray(declared)) { - const part = asRecord(entry); - - if (!part) { - return { ok: false, reason: "A data part is not a typed value." }; - } - - for (const key of ["endian", "pad_to", "align"]) { - if (key in part) { - return { - ok: false, - reason: `A data part carries ${key}, which this vocabulary does not have.`, - }; - } - } - - const type = part.type; - - if (typeof type !== "string" || !(type === "bytes" || type === "u8" || type === "u64")) { - return { - ok: false, - reason: `A data part is declared ${String(type)}, which object-form data does not have.`, - }; - } - - // A part's value can be a reference — every one in the corpus is — so it is resolved - // before it is encoded. Encoding the reference text itself would produce bytes that - // look like a payload and are the name of one. - const resolved = - typeof part.value === "string" && !part.value.startsWith("0x") - ? resolve(part.value) - : { ok: true as const, value: part.value }; - - if (!resolved.ok) { - return { ok: false, reason: `A data part could not be resolved: ${resolved.reason}` }; - } - - // Big-endian, because there is no key to say otherwise and a length-prefixed binary - // layout written by hand reads in that order. - const encoded = encodeTyped(type, resolved.value); - - if (!encoded.ok) { - return encoded; - } - - hex += type === "u64" ? reverse(encoded.hex) : encoded.hex; - } - - return { hex, ok: true }; +export function encodeDataParts(data: unknown, resolve?: PartResolver): EncodedBytes { + return encodeMetadataParts(data, resolve); } function encodeTyped(type: string, value: unknown): EncodedBytes { diff --git a/packages/tx-manifest/src/evaluation/inputOrder.test.ts b/packages/tx-manifest/src/evaluation/inputOrder.test.ts new file mode 100644 index 0000000..1d30e57 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/inputOrder.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; + +import { placeInputs } from "./inputOrder"; + +/** + * The layout on its own, with the pieces written as their own names. + * + * A slot here is a string because what a slot is belongs to the caller: the review hands this + * covenant records and chosen outputs, and nothing about placing them depends on which. The + * order that comes back is the order the wallet adds its inputs in. + */ +function order(declared: { id?: string; slots: string[]; stated?: number }[]): string[] { + return placeInputs(declared).order; +} + +describe("laying out the inputs a document states positions for", () => { + // The wallet's own habit is covenants first, so an input it supplies could only ever land + // after them. Every published contract that fixes an input at zero fixes one the wallet + // supplies, which is the whole reason this exists. + test("puts a wallet's own input ahead of a covenant when the document says zero", () => { + expect( + order([ + { id: "borrower_nft_in", slots: ["nft"], stated: 0 }, + { id: "active_offer_in", slots: ["covenant"], stated: 1 }, + { id: "fee_input", slots: ["fee"] }, + ]), + ).toEqual(["nft", "covenant", "fee"]); + }); + + test("leaves the order alone when the document asks for the one it would have used", () => { + expect( + order([ + { id: "offer_in", slots: ["covenant"], stated: 0 }, + { id: "nft_in", slots: ["nft"], stated: 1 }, + { id: "fee_input", slots: ["fee"] }, + ]), + ).toEqual(["covenant", "nft", "fee"]); + }); + + // A negative position cannot be read without knowing how many inputs there are, which is + // exactly why the total is counted before anything is placed rather than while it is. + test("counts a negative position from the end", () => { + expect( + order([ + { id: "covenant_in", slots: ["covenant"] }, + { id: "last_in", slots: ["last"], stated: -1 }, + { id: "fee_input", slots: ["fee"] }, + ]), + ).toEqual(["covenant", "fee", "last"]); + }); + + // The wallet needed two of its own outputs to cover one declared amount. They stay together + // from the stated position, because a declared input whose pieces are scattered through the + // transaction has no one position for a contract to read. + test("keeps the outputs funding one declared input together, starting where it was asked", () => { + expect( + order([ + { id: "covenant_in", slots: ["covenant"] }, + { id: "collateral_in", slots: ["one", "two"], stated: 0 }, + { id: "fee_input", slots: ["fee"] }, + ]), + ).toEqual(["one", "two", "covenant", "fee"]); + }); + + test("fills what is left in declaration order, so nothing states a place it does not need", () => { + expect( + order([ + { id: "first", slots: ["first"] }, + { id: "second", slots: ["second"] }, + { id: "placed", slots: ["placed"], stated: 1 }, + ]), + ).toEqual(["first", "placed", "second"]); + }); + + test("reports where each declared input landed, by the name the document gives it", () => { + const placement = placeInputs([ + { id: "nft_in", slots: ["nft"], stated: 2 }, + { id: "covenant_in", slots: ["covenant"] }, + { id: "fee_input", slots: ["fee_one", "fee_two"] }, + ]); + + expect([...placement.at]).toEqual([ + ["nft_in", 2], + ["covenant_in", 0], + ["fee_input", 1], + ]); + }); +}); + +/** + * What placement must not do, which is turn an impossible document into a built transaction. + * + * Being able to honour a position is not a licence to honour every one of them. A piece that + * cannot go where it was asked keeps whatever place is left, and the caller's own check reads + * that place and refuses by name — so the layout is where the refusal gets its evidence, not + * where it gets suppressed. + */ +describe("a position that cannot be honoured", () => { + test("leaves the second of two inputs claiming one index somewhere it did not ask for", () => { + const placement = placeInputs([ + { id: "first_in", slots: ["first"], stated: 0 }, + { id: "second_in", slots: ["second"], stated: 0 }, + ]); + + expect(placement.order).toEqual(["first", "second"]); + expect(placement.at.get("second_in")).toBe(1); + }); + + test("leaves an input asking for a place past the end of the transaction elsewhere", () => { + const placement = placeInputs([ + { id: "covenant_in", slots: ["covenant"] }, + { id: "far_in", slots: ["far"], stated: 9 }, + ]); + + expect(placement.order).toEqual(["covenant", "far"]); + expect(placement.at.get("far_in")).toBe(1); + }); + + // The hazard a two-output selection creates: the run takes the covenant's place, so the + // covenant cannot have it. Neither is silently moved — both land, and the check that reads + // where they landed is what stops the transaction. + test("leaves a covenant displaced by a run that reached its index", () => { + const placement = placeInputs([ + { id: "nft_in", slots: ["one", "two"], stated: 0 }, + { id: "covenant_in", slots: ["covenant"], stated: 1 }, + ]); + + expect(placement.order).toEqual(["one", "two", "covenant"]); + expect(placement.at.get("covenant_in")).toBe(2); + }); + + test("gives no place at all to a declared input the wallet builds nothing for", () => { + const placement = placeInputs([ + { id: "covenant_in", slots: ["covenant"] }, + { id: "unfunded_in", slots: [], stated: 1 }, + ]); + + expect(placement.order).toEqual(["covenant"]); + expect(placement.at.has("unfunded_in")).toBe(false); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/inputOrder.ts b/packages/tx-manifest/src/evaluation/inputOrder.ts new file mode 100644 index 0000000..91c303d --- /dev/null +++ b/packages/tx-manifest/src/evaluation/inputOrder.ts @@ -0,0 +1,128 @@ +/** + * Laying the transaction's inputs out in the order the document states rather than the wallet's. + * + * A covenant introspects positions: a program that reads input zero reads whatever is at input + * zero, and one that asserts its own index will not run anywhere else. So where an input sits is + * part of what the document says, and a wallet with an order of its own — every covenant first, + * then whatever it chose to fund the rest — honours its own habit instead. Where the two agree + * nothing is lost, and where they disagree the habit builds a transaction the contract rejects + * while the wallet that could have built the right one refuses to build anything. + * + * What is here decides the order and nothing else. It does not decide whether the order is + * acceptable: a piece it could not put where the document asked keeps whatever place it was + * given, and `checkPositions` reads the result and refuses by name. Keeping placement apart from + * the check is what keeps an impossible document impossible — two inputs claiming one index + * cannot both have it, in any order — and it is why being able to place is not a way to stop + * refusing. + */ + +/** One input the action declares, and the pieces the wallet would build it from. */ +export type PlaceableInput<Slot> = { + /** The manifest's id, so where it landed can be reported against what it asked for. */ + id?: string; + /** + * What this declared input is built from, in the order those pieces must be spent. + * + * More than one when the wallet needed more than one of its own outputs to cover the amount + * the action asks for, and they stay together: a declared input whose pieces are scattered + * through the transaction has no one position for a contract to read. None at all when the + * wallet builds nothing for it, which is a declaration with no place rather than a place of + * nought — it is left out, and the caller says what that means. + */ + slots: Slot[]; + /** + * Where the document says it goes: from the start when positive, from the end when negative. + * + * Absent when the document says nothing, which is most inputs — a fee input is the wallet's + * own business and every published document leaves it unplaced. + */ + stated?: number; +}; + +export type InputPlacement<Slot> = { + /** Where each declared input's first piece landed, by the id the document names it. */ + at: Map<string, number>; + /** The transaction's inputs, in the order the wallet must add them. */ + order: Slot[]; +}; + +/** + * Works out which input goes where, honouring every stated position that can be honoured. + * + * Stated positions are taken in the order the action declares them, because a place already + * taken cannot be given twice and declaration order is the only tie-break that does not depend + * on how the wallet happened to fund something. Everything else falls into what is left, in the + * same order — so an input nothing was stated for still lands somewhere the caller can name. + */ +export function placeInputs<Slot>(declared: PlaceableInput<Slot>[]): InputPlacement<Slot> { + const total = declared.reduce((count, input) => count + input.slots.length, 0); + const laid: (Slot | undefined)[] = Array.from({ length: total }); + const at = new Map<string, number>(); + const remaining: PlaceableInput<Slot>[] = []; + + for (const input of declared) { + if (input.slots.length === 0) { + continue; + } + + const wanted = resolve(input.stated, total); + + if (wanted === undefined || !vacant(laid, wanted, input.slots.length)) { + remaining.push(input); + continue; + } + + for (const [offset, slot] of input.slots.entries()) { + laid[wanted + offset] = slot; + } + + if (input.id !== undefined) { + at.set(input.id, wanted); + } + } + + // What is left over is either an input the document stated nothing for or one whose stated + // place was already taken. The second still gets a place rather than none, so the refusal + // that follows can say where the wallet would have put it instead of only that it could not. + let next = 0; + + for (const input of remaining) { + for (const slot of input.slots) { + while (laid[next] !== undefined) { + next += 1; + } + + if (input.id !== undefined && !at.has(input.id)) { + at.set(input.id, next); + } + + laid[next] = slot; + } + } + + return { at, order: laid.filter((slot): slot is Slot => slot !== undefined) }; +} + +/** A stated position read against the total, since a negative one counts from the end. */ +function resolve(stated: number | undefined, total: number): number | undefined { + if (stated === undefined) { + return undefined; + } + + return stated < 0 ? total + stated : stated; +} + +/** Whether a run of that length starting there is inside the transaction and still empty. */ +function vacant<Slot>(laid: (Slot | undefined)[], from: number, length: number): boolean { + if (!Number.isInteger(from) || from < 0 || from + length > laid.length) { + return false; + } + + for (let at = from; at < from + length; at += 1) { + if (laid[at] !== undefined) { + return false; + } + } + + return true; +} diff --git a/packages/tx-manifest/src/evaluation/leafParts.ts b/packages/tx-manifest/src/evaluation/leafParts.ts new file mode 100644 index 0000000..63c87b2 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/leafParts.ts @@ -0,0 +1,177 @@ +import { asRecord } from "../document/json"; +import { parseReference } from "../document/references"; +import type { EncodedBytes } from "./encode"; + +/** + * An extra taproot leaf written as a kind and a list of parts. + * + * `extra_leaves` has a second shape beside the three `encode.ts` already reads. A leaf can be + * written `{"type": "tapdata", "payload": [ … ]}`, where the parts are the leaf's bytes and the + * type names the kind of leaf rather than the type of a value. + * + * **What a `tapdata` leaf is.** The compiler takes a JSON array of hex strings, one per leaf, + * and puts each one in a storage slot (`smplx/crates/wasm/src/lib.rs` L185-196). A slot becomes + * a hidden taproot node whose hash is `tap_data_hash(slot)` — `sha256(tag ‖ tag ‖ slot)` with + * `tag = sha256("TapData")` (`smplx/crates/sdk/src/utils.rs` L44-53), added to the tree at + * `crates/sdk/src/program/core.rs` L395-405. So the leaf's payload is one run of bytes hashed + * as one run, and `tapdata` is the only kind of extra leaf this compiler can build. Any other + * kind is refused by name rather than built as if it were this one: an extra leaf is part of + * the tree the address is derived from, and a leaf of the wrong kind derives a different + * address without failing anywhere. + * + * **Why the parts concatenate, and what is not established about that.** The bytes are one run, + * and the contract that reads these leaves composes its own copy through a streaming SHA-256 + * context — `jet::tapdata_init()`, then a `sha_256_ctx_8_add_32` per value, then finalize. A + * sequence of adds into one context is a concatenation, in the order they are made. That is the + * reading. What no source settles is the multi-part case itself: every payload any published + * document writes holds exactly one part, so concatenation of several is reasoned from the + * streaming model rather than measured against a document. It is recorded here as that rather + * than presented as established. + * + * **A part is a leaf item.** The parts use the vocabulary `extra_leaves` already has — a hex + * literal, or a typed value with `endian`, `pad_to` and `align` — so nothing new is invented for + * them and `encode.ts` encodes each one. What is added at this position is that a part's value + * may name something instead of being something, which is the only reason this file needs a + * resolver. + * + * **No byte order is carried across.** This file chooses no endianness. A part states its own, + * and the vocabulary's own defaults are `encode.ts`'s and stay there. The sibling vocabulary on + * an output's `data` reached the opposite integer default; nothing from it is read here. + */ + +/** + * How a part's value is resolved when it names something rather than being something. + * + * Supplied by the caller for the same reason `metadataParts` takes one: what a name may refer to + * is decided by the position the name sits at, and this encoder does not know its own position. + */ +export type LeafPartResolver = ( + reference: string, +) => { ok: true; value: unknown } | { ok: false; reason: string }; + +/** The kinds of extra leaf this runtime can build, which is the set the compiler can build. */ +const LEAF_KINDS = new Set(["tapdata"]); + +/** + * Whether a leaf record declares a kind of leaf rather than a type of value. + * + * A record carrying a payload is one whatever its type says, so an unrecognised kind is refused + * as a kind — which names what is wrong — instead of falling through to the value vocabulary and + * being refused for carrying no value, which names something else. + */ +export function declaresLeafKind(type: string, leaf: Record<string, unknown>): boolean { + return LEAF_KINDS.has(type) || "payload" in leaf; +} + +/** + * Encodes one extra leaf written as a kind and a payload. + * + * `encodeItem` is the leaf-item encoder, applied to each part after any name in it is resolved. + * Every refusal names the part it is about, because its position in the payload is the only name + * a part has — the format gives them no ids — and a person holding a request needs to know which + * of the values the wallet could not write. + */ +export function encodeLeafPayload( + kind: string, + leaf: Record<string, unknown>, + encodeItem: (item: unknown) => EncodedBytes, + resolve?: LeafPartResolver, +): EncodedBytes { + if (!LEAF_KINDS.has(kind)) { + return { + ok: false, + reason: `An extra leaf is declared ${kind}, which is not a kind of leaf this runtime can build.`, + }; + } + + if (!("payload" in leaf)) { + return { ok: false, reason: `The ${kind} extra leaf carries no payload.` }; + } + + if (!Array.isArray(leaf.payload)) { + return { + ok: false, + reason: `The ${kind} extra leaf carries a payload that is not a list of parts.`, + }; + } + + // An empty payload is a leaf declared and left unsaid. It would hash to a real leaf and + // derive a real address, so it is refused rather than encoded as no bytes. + if (leaf.payload.length === 0) { + return { ok: false, reason: `The ${kind} extra leaf carries an empty payload.` }; + } + + let hex = ""; + let position = 0; + + for (const entry of leaf.payload) { + position += 1; + + const part = substitute(entry, position, resolve); + + if (!part.ok) { + return part; + } + + const encoded = encodeItem(part.item); + + if (!encoded.ok) { + return { ok: false, reason: `payload part ${position}: ${encoded.reason}` }; + } + + hex += encoded.hex; + } + + return { hex, ok: true }; +} + +/** + * One part with whatever it names looked up, ready for the leaf-item encoder. + * + * The rule for telling a name from a value is the format's own reference grammar and nothing + * else — the same parser every other reference position in this runtime asks. It can read a + * literal as a name: a thirty-two-byte value written without `0x` and opening with a letter is a + * perfect reference. That direction costs a refusal naming the text, which a person can act on + * and which `0x` in front of it settles for good. The other direction would cost bytes nobody + * checks, in a leaf the address is derived from. + */ +function substitute( + entry: unknown, + position: number, + resolve: LeafPartResolver | undefined, +): { item: unknown; ok: true } | { ok: false; reason: string } { + const record = asRecord(entry); + + if (record && "payload" in record) { + return { + ok: false, + reason: `payload part ${position} carries a payload of its own, and a part is a value rather than a leaf.`, + }; + } + + const written = record ? record.value : entry; + const named = + typeof written === "string" && parseReference(written) !== undefined ? written : undefined; + + if (named === undefined) { + return { item: entry, ok: true }; + } + + if (!resolve) { + return { + ok: false, + reason: `payload part ${position} names "${named}", and this encoding was asked for with nothing to resolve a name against.`, + }; + } + + const resolved = resolve(named); + + if (!resolved.ok) { + return { + ok: false, + reason: `payload part ${position} is wired to "${named}", which did not resolve: ${resolved.reason}`, + }; + } + + return { item: record ? { ...record, value: resolved.value } : resolved.value, ok: true }; +} diff --git a/packages/tx-manifest/src/evaluation/metadataParts.test.ts b/packages/tx-manifest/src/evaluation/metadataParts.test.ts new file mode 100644 index 0000000..e734ad6 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/metadataParts.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test } from "bun:test"; + +import lendingManifest from "../__fixtures__/current/lending_v3.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; +import { encodeMetadataParts, type PartResolver } from "./metadataParts"; +import { planAction } from "./plan"; + +// What an action publishes about itself, so its protocol's own reader can find it again. The +// vocabulary is closed on purpose: bytes in the wrong order still make a well-formed output in +// a transaction that confirms, and the only thing lost is that the record cannot be matched. + +/** Resolves nothing, so the value under test is the one written in the part. */ +function bytes(value: unknown, resolve?: PartResolver): string { + const result = encodeMetadataParts(value, resolve); + + return result.ok ? result.hex : result.reason; +} + +/** One part, which is the unit almost every question here is about. */ +function part(declared: Record<string, unknown>, resolve?: PartResolver): string { + return bytes({ parts: [declared] }, resolve); +} + +describe("what each declared part type encodes to", () => { + test("bytes are taken as they are, at whatever length the value carries", () => { + expect(part({ type: "bytes", value: "0xa9b4ade7" })).toBe("a9b4ade7"); + expect(part({ type: "bytes", value: "0x" })).toBe(""); + }); + + // Measured rather than reasoned. A reader of these bytes in production takes every integer + // width here little-endian, and the published document agrees: it writes `endian: "le"` on + // one of them and leaves the others to the default, which only makes sense if the default is + // the same order. The runtime previously wrote one of these widths the other way round, on + // the reasoning that a hand-written layout reads big-endian. + test("integers are little-endian, which is the default when nothing says otherwise", () => { + expect(part({ type: "u8", value: 1 })).toBe("01"); + expect(part({ type: "u16", value: 1000 })).toBe("e803"); + expect(part({ type: "u32", value: 900_000 })).toBe("a0bb0d00"); + expect(part({ type: "u64", value: 100_000 })).toBe("a086010000000000"); + }); + + test("a decimal string is the number it spells, past what a double holds", () => { + expect(part({ type: "u64", value: "9007199254740993" })).toBe("0100000000002000"); + }); + + // The one modifier this vocabulary has. `le` is what the documents write, and it restates the + // default rather than changing it; `be` is the reverse. + test("endian states the order of an integer, and le is the default said out loud", () => { + expect(part({ endian: "le", type: "u64", value: 1 })).toBe("0100000000000000"); + expect(part({ endian: "be", type: "u64", value: 1 })).toBe("0000000000000001"); + }); + + // An asset id is stated one way and published the other. The reader writes it out of its + // internal thirty-two-byte array, and that array is the reverse of the form every document, + // request and screen states an id in — the same turn this wallet's chain reader performs in + // the other direction when it reads an output's asset. + test("an asset id is turned round, because it is stated in reverse of how it is published", () => { + expect(part({ type: "liquid.asset_id", value: STATED_ASSET })).toBe(PUBLISHED_ASSET); + }); + + test("and an asset id is accepted with or without the prefix, being the same id", () => { + expect(part({ type: "liquid.asset_id", value: `0x${STATED_ASSET}` })).toBe(PUBLISHED_ASSET); + }); + + test("parts are concatenated in the order they are written", () => { + expect( + bytes({ + parts: [ + { type: "bytes", value: "0xa9b4ade7" }, + { type: "u8", value: 2 }, + ], + }), + ).toBe("a9b4ade702"); + }); +}); + +/** A deployment carrying exactly one field, so that everything else refuses by name. */ +const resolve: PartResolver = (name) => + name === "instance.PROGRAM_ID" + ? { ok: true, value: "0xa9b4ade7" } + : { ok: false, reason: `nothing named ${name}` }; + +// The rule is the format's own reference grammar and nothing else: a name, optionally +// namespaced, opening with a letter or an underscore. It is the same parser every other +// reference position asks, so a reference means one thing across the format. +describe("telling a name to look up from the bytes themselves", () => { + test("a value the grammar accepts is looked up, never written", () => { + expect(part({ type: "bytes", value: "instance.PROGRAM_ID" }, resolve)).toBe("a9b4ade7"); + }); + + // A document writing bytes directly had them looked up as a name and refused for not + // existing. This is the shape that failed: the ASCII of a short word, written as bare hex. + test("a bare hexadecimal literal opening with a digit is bytes, because it cannot be a name", () => { + expect(part({ type: "bytes", value: "6275726e" }, resolve)).toBe("6275726e"); + }); + + test("a prefixed literal is bytes for the same reason, the prefix opening with a digit", () => { + expect(part({ type: "bytes", value: "0xdeadbeef" }, resolve)).toBe("deadbeef"); + }); + + // The direction the rule errs in, stated as a test so that it is a decision rather than an + // accident. A four-byte tag written `a9b4ade7` is a perfect reference to a parameter of that + // name, and an asset id opening with a letter is a perfect reference to a sixty-four + // character one. Both are looked up; neither can become bytes nobody checked. Writing `0x` + // in front settles it for good. + test("a literal that could also be a name is looked up, and refuses by naming the text", () => { + expect(part({ type: "bytes", value: "a9b4ade7" }, resolve)).toContain("a9b4ade7"); + expect( + part({ type: "liquid.asset_id", value: STATED_ASSET.replace(/^0/, "a") }, resolve), + ).toContain("nothing named"); + }); + + test("and a name nothing resolves refuses rather than encoding the name", () => { + const refusal = part({ type: "bytes", value: "instance.MISSING" }, resolve); + + expect(refusal).toContain("instance.MISSING"); + expect(refusal).toContain("Data part 1"); + }); + + // A resolved value is a value. It is never read as a name a second time, which is what keeps + // a deployment field holding `a9b4ade7` from being looked up as a parameter. + test("what a name resolves to is bytes, not another name", () => { + expect( + part({ type: "bytes", value: "instance.PROGRAM_ID" }, () => ({ + ok: true, + value: "a9b4ade7", + })), + ).toBe("a9b4ade7"); + }); +}); + +// Widening the vocabulary is not opening it. Everything below stays refused, and each refusal +// says which part the wallet could not write. +describe("what it still refuses, and by name", () => { + test("the sibling vocabulary's padding, which no document writes here", () => { + expect(part({ pad_to: 8, type: "u8", value: 1 })).toContain("pad_to"); + expect(part({ align: "left", type: "u8", value: 1 })).toContain("align"); + }); + + test("the sibling vocabulary's types, which no document writes here either", () => { + expect(part({ type: "bytes32", value: `0x${"11".repeat(32)}` })).toContain("bytes32"); + expect(part({ type: "pubkey", value: `0x${"22".repeat(32)}` })).toContain("pubkey"); + }); + + test("a type nobody has mapped at all", () => { + expect(part({ type: "u128", value: 1 })).toContain("u128"); + expect(part({ type: 7, value: 1 })).toContain("7"); + }); + + // On a run of bytes there is no number whose order it could be describing, and on an asset id + // the turn is already what the type means — a second one stated beside it is two instructions + // about the same bytes, and no document says which wins. + test("endian where it has no established meaning", () => { + expect(part({ endian: "be", type: "bytes", value: "0x0102" })).toContain("endian"); + expect(part({ endian: "be", type: "liquid.asset_id", value: STATED_ASSET })).toContain( + "endian", + ); + }); + + test("an endianness this vocabulary cannot state", () => { + const refusal = part({ endian: "middle", type: "u32", value: 1 }); + + expect(refusal).toContain("middle"); + expect(refusal).toContain("le and be"); + }); + + test("a value too wide for the width it was declared at, rather than truncating it", () => { + expect(part({ type: "u8", value: 256 })).toContain("256"); + expect(part({ type: "u16", value: 65_536 })).toContain("65536"); + }); + + test("a negative number, these encodings being unsigned", () => { + expect(part({ type: "u32", value: -1 })).toContain("-1"); + }); + + test("an asset id that is not thirty-two bytes", () => { + expect(part({ type: "liquid.asset_id", value: "0x1122" })).toContain("4 hexadecimal"); + }); + + test("bytes that are not a whole number of bytes, and bytes that are not hexadecimal", () => { + expect(part({ type: "bytes", value: "0x010" })).toContain("010"); + expect(part({ type: "bytes", value: "0xzzzz" })).toContain("zzzz"); + }); + + test("a part with a type and no value", () => { + expect(part({ type: "u8" })).toContain("no value"); + }); + + test("data that is not a parts list at all", () => { + expect(bytes({ parts: "0x00" })).toContain("parts"); + expect(bytes({ parts: [7] })).toContain("Data part 1"); + }); + + test("and every refusal says which part, the position being the only name a part has", () => { + expect( + bytes({ + parts: [ + { type: "u8", value: 1 }, + { type: "u8", value: 1 }, + { type: "u8", value: 999 }, + ], + }), + ).toContain("Data part 3"); + }); +}); + +/** + * Thirty-two distinct bytes, so that a reversal that did not happen is visible. + * + * An id of one repeated byte reads the same either way round, which is how an encoder that + * never turned one could pass a test that looked like it checked. + */ +const STATED_ASSET = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; +const PUBLISHED_ASSET = "201f1e1d1c1b1a191817161514131211100f0e0d0c0b0a090807060504030201"; + +/** + * A deployment of the published protocol, filled in far enough to plan the two actions that + * publish a record. The tags are the constants those documents carry as defaults. + */ +const LENDING = { + COLLATERAL_AMOUNT: "500000", + COLLATERAL_ASSET_ID: "c0".repeat(32), + CURRENT_DEBT: "110000", + FACTORY_ASSET_ID: "fa".repeat(32), + FACTORY_PROGRAM_ID: "dd1e7f89", + ISSUING_UTXOS_COUNT: "2", + LENDER_NFT_ASSET_ID: "1e".repeat(32), + LENDING_PROGRAM_ID: "a9b4ade7", + LOAN_EXPIRATION_TIME: "900000", + PRINCIPAL_AMOUNT: "100000", + PRINCIPAL_ASSET_ID: STATED_ASSET, + PRINCIPAL_INTEREST_RATE: "1000", + PROTOCOL_FEE_KEEPER_ASSET_ID: "fe".repeat(32), + REISSUANCE_FLAGS: "0", + BORROWER_NFT_ASSET_ID: "b0".repeat(32), +}; + +const SCOPE: ReferenceScope = { + fee: 0n, + instance: LENDING, + params: { LENDER_VAULT_AMOUNT: "105000", TOTAL_PROTOCOL_FEE: "5000" }, +}; + +/** The script the action's record output pays to, which is the record and its OP_RETURN. */ +function publishedRecord(name: string): string { + const action = findAction( + normaliseManifest(lendingManifest as unknown as Record<string, unknown>).manifest, + name, + ); + const plan = action && planAction(action, SCOPE); + + if (!plan?.ok) { + throw new Error(plan ? plan.reason : `this document declares no ${name}`); + } + + const record = plan.plan.outputs.find( + (output) => output.target.kind === "data" && output.target.hex !== "6a", + ); + + return record?.target.kind === "data" ? record.target.hex : ""; +} + +// The claim these two make is not that a helper agrees with itself. Each document states the +// length of the record it publishes — one in a comment on the layout, one in the widths it +// declares — and both lengths come out of the push byte the wallet writes. +describe("the records the published corpus asks a wallet to publish", () => { + test("a factory's creation record is its tag, a count and a bitmask: thirteen bytes", () => { + expect(publishedRecord("CreateFactory")).toBe("6a0ddd1e7f89020000000000000000"); + }); + + test("an offer's creation record is fifty bytes, and its asset id is turned round", () => { + expect(publishedRecord("CreateOffer")).toBe( + `6a32a9b4ade7${PUBLISHED_ASSET}a086010000000000a0bb0d00e803`, + ); + }); + + test("and the wallet writes the length each document states", () => { + expect(payloadLength(publishedRecord("CreateFactory"))).toBe(13); + expect(payloadLength(publishedRecord("CreateOffer"))).toBe(50); + }); +}); + +/** The push byte an OP_RETURN carries below 76 bytes, read back as a number. */ +function payloadLength(script: string): number { + return Number.parseInt(script.slice(2, 4), 16); +} diff --git a/packages/tx-manifest/src/evaluation/metadataParts.ts b/packages/tx-manifest/src/evaluation/metadataParts.ts new file mode 100644 index 0000000..9195505 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/metadataParts.ts @@ -0,0 +1,337 @@ +import { asArray, asRecord } from "../document/json"; +import { parseReference } from "../document/references"; +import type { EncodedBytes } from "./encode"; + +/** + * An output's object-form `data`: the bytes an action publishes about itself. + * + * A protocol writes this so that its own reader can find the action again afterwards. That is + * the whole of what the position is for, and it is why the failure here is worse than an + * error. Bytes in the wrong order still make a well-formed output, in a transaction that + * confirms and pays what it said it would; what is lost is only that the record cannot be + * matched. The action happened, the money moved, and nothing can find it. + * + * So the vocabulary is closed and stays closed. A type or a modifier nobody has measured is + * refused by name rather than encoded on a resemblance to one that has been. + * + * **The integers are little-endian, and that was measured rather than reasoned.** A deployed + * reader of these bytes decodes every integer width in this table with the platform's + * little-endian conversion and writes them back the same way; the published document agrees, + * declaring `endian: "le"` on one of them and leaving the rest to the default. Reading the + * document alone could not have settled it: a layout written by hand reads big-endian on the + * page, and the runtime previously encoded one of these widths that way on exactly that + * reasoning. + * + * **An asset id is turned round.** The same reader writes an id straight out of its internal + * thirty-two-byte array, and that array is the reverse of the form an id is written in + * everywhere a person or a document states one — the reversal is in the hashing library, which + * displays this hash backwards, and this wallet's own chain reader performs the same turn in + * the other direction when it reads an output's asset. So the id a request supplies is + * reversed here, at the one place a stated id becomes published bytes. + * + * **What is deliberately absent.** The sibling vocabulary on a covenant's `extra_leaves` also + * has `bytes32`, `pubkey`, `pad_to` and `align`. No document writes any of them at this + * position and no reader has been observed consuming them here, so they are refused by name. + * Sharing a spelling with the other vocabulary is not evidence of sharing a meaning, and the + * two are implemented apart for that reason. + */ + +/** How a declared part type turns its value into bytes. */ +type PartEncoding = "bytes" | "integer" | "reversedBytes"; + +type PartType = { + /** The exact width a value of this type occupies, for the types that fix one. */ + bytes?: number; + encoding: PartEncoding; + /** What a value of this type should look like, for a refusal that can be acted on. */ + shape: string; +}; + +/** + * The part types this vocabulary encodes. + * + * Every type the published documents declare at this position is here, and nothing else is. + * An integer's range is derived from its width rather than written beside it, because at this + * position the two are one fact and two copies of one fact eventually disagree. + */ +const PART_TYPES: Record<string, PartType> = { + /** + * A run of bytes of whatever length the value carries. + * + * No width is declared and none is checked: the documents use this for a short fixed tag + * that prefixes the record, and the reader takes its length from its own layout rather than + * from anything the part says. + */ + bytes: { encoding: "bytes", shape: "bytes, as an even number of hexadecimal characters" }, + /** + * An asset id, written in reverse of how it is stated. + * + * This is the entry where passing the value through would produce a perfectly valid output + * carrying an id no reader will match, and nothing downstream would notice. + */ + "liquid.asset_id": { + bytes: 32, + encoding: "reversedBytes", + shape: "an asset id: 32 bytes as 64 hexadecimal characters", + }, + u8: { bytes: 1, encoding: "integer", shape: "a whole number that fits in 1 byte" }, + u16: { bytes: 2, encoding: "integer", shape: "a whole number that fits in 2 bytes" }, + u32: { bytes: 4, encoding: "integer", shape: "a whole number that fits in 4 bytes" }, + u64: { bytes: 8, encoding: "integer", shape: "a whole number that fits in 8 bytes" }, +}; + +/** + * The two byte orders this vocabulary can state, and what each one does. + * + * `le` is what the documents write and what the deployed reader consumes, and it is the default + * when the key is absent. `be` is the reverse, which is the meaning the same word carries in + * the format's other byte vocabulary; no document writes it at this position, so it is carried + * across rather than measured. Any other spelling is refused rather than treated as one of + * these two: a value quietly read as the default is a different number in the record. + */ +const BYTE_ORDERS: Record<string, "be" | "le"> = { be: "be", le: "le" }; + +/** Keys the sibling vocabulary has and this one has no measured meaning for. */ +const UNSUPPORTED_MODIFIERS = ["align", "pad_to"]; + +/** + * How a part's value is resolved when it names something rather than being something. + * + * The caller supplies this because what a name may refer to is decided by the position the + * name sits at, and this encoder does not know its own position. + */ +export type PartResolver = ( + reference: string, +) => { ok: true; value: unknown } | { ok: false; reason: string }; + +/** + * Encodes an output's object-form `data`. + * + * `{parts: [{type, value}, …]}`, concatenated in the order they are written. Every refusal + * names the part it is about, because a person holding a request needs to know which of the + * values the wallet could not write, and its position in the layout is the only name a part + * has — the format gives them no ids. + */ +export function encodeMetadataParts( + data: unknown, + resolve: PartResolver = (reference) => ({ ok: true, value: reference }), +): EncodedBytes { + const declared = asRecord(data)?.parts; + + if (!Array.isArray(declared)) { + return { ok: false, reason: "Object-form data carries no parts list." }; + } + + let hex = ""; + let position = 0; + + for (const entry of asArray(declared)) { + position += 1; + + const part = asRecord(entry); + + if (!part) { + return { ok: false, reason: `Data part ${position} is not a typed value.` }; + } + + const encoded = encodePart(part, position, resolve); + + if (!encoded.ok) { + return encoded; + } + + hex += encoded.hex; + } + + return { hex, ok: true }; +} + +function encodePart( + part: Record<string, unknown>, + position: number, + resolve: PartResolver, +): EncodedBytes { + for (const key of UNSUPPORTED_MODIFIERS) { + if (key in part) { + return { + ok: false, + reason: `Data part ${position} carries ${key}, which this vocabulary does not have.`, + }; + } + } + + const type = part.type; + const declared = typeof type === "string" ? PART_TYPES[type] : undefined; + + if (!declared) { + return { + ok: false, + reason: `Data part ${position} is declared ${String(type)}, which object-form data does not have.`, + }; + } + + if (!("value" in part)) { + return { ok: false, reason: `Data part ${position}, declared ${type}, carries no value.` }; + } + + const order = byteOrder(part, position, declared); + + if (!order.ok) { + return order; + } + + const named = referencedName(part.value); + const resolved = named === undefined ? { ok: true as const, value: part.value } : resolve(named); + + if (!resolved.ok) { + return { + ok: false, + reason: `Data part ${position} is wired to "${named}", which did not resolve: ${resolved.reason}`, + }; + } + + const wrong = (found: string): EncodedBytes => ({ + ok: false, + reason: `Data part ${position} is declared ${String(type)}, which is ${declared.shape}. Got ${found}.`, + }); + + return declared.encoding === "integer" + ? encodeInteger(declared, resolved.value, order.order, wrong) + : encodeBytes(declared, resolved.value, wrong); +} + +/** + * Which way round a part's integer is written. + * + * The key is read only where it has a measured meaning. On a run of bytes there is no number + * whose order it could be describing, and on an asset id the turn is already part of what the + * type means — a second one stated beside it would be two instructions about the same bytes, + * and no document says which wins. + */ +function byteOrder( + part: Record<string, unknown>, + position: number, + declared: PartType, +): { ok: false; reason: string } | { ok: true; order: "be" | "le" } { + if (!("endian" in part)) { + return { ok: true, order: "le" }; + } + + if (declared.encoding !== "integer") { + return { + ok: false, + reason: `Data part ${position} carries endian, which this vocabulary states only about an integer.`, + }; + } + + const order = typeof part.endian === "string" ? BYTE_ORDERS[part.endian] : undefined; + + return order === undefined + ? { + ok: false, + reason: `Data part ${position} declares endian ${String(part.endian)}, and this vocabulary's byte orders are le and be.`, + } + : { ok: true, order }; +} + +/** Little-endian unless the part said otherwise; a value too wide is refused, never truncated. */ +function encodeInteger( + declared: PartType, + value: unknown, + order: "be" | "le", + wrong: (found: string) => EncodedBytes, +): EncodedBytes { + const width = declared.bytes ?? 0; + const count = asCount(value); + + if (count === undefined || count < 0n) { + return wrong(quoted(value)); + } + + if (count > 2n ** BigInt(width * 8) - 1n) { + return wrong(`${count}`); + } + + const big = count.toString(16).padStart(width * 2, "0"); + + return { hex: order === "be" ? big : reverseBytes(big), ok: true }; +} + +/** Hex as it is, or turned round for the types stated in one order and published in the other. */ +function encodeBytes( + declared: PartType, + value: unknown, + wrong: (found: string) => EncodedBytes, +): EncodedBytes { + if (typeof value !== "string") { + return wrong(quoted(value)); + } + + const digits = withoutHexPrefix(value.trim()); + + if (digits.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(digits)) { + return wrong(quoted(value)); + } + + if (declared.bytes !== undefined && digits.length !== declared.bytes * 2) { + return wrong(`${digits.length} hexadecimal characters`); + } + + const lower = digits.toLowerCase(); + + return { hex: declared.encoding === "reversedBytes" ? reverseBytes(lower) : lower, ok: true }; +} + +/** + * Whether a part's value names something or is something. + * + * The rule is the format's own reference grammar and nothing else. `parseReference` accepts a + * name — optionally namespaced with a dot — whose first character is a letter or an underscore; + * everything it rejects is a literal. It is the same parser every other reference position in + * this runtime asks, so a reference means one thing across the format rather than one thing per + * site, and it is a decision about the grammar rather than about how the text looks. + * + * The two mistakes it can make are not the same size, and this is which way round it errs. + * + * It never reads a reference as bytes. Anything the grammar accepts is looked up, and a name + * the request does not carry refuses by name — it can never become a payload that looks like + * one and is not. + * + * It can read a literal as a reference, and the format leaves no way to tell them apart: a + * four-byte tag written `a9b4ade7` is a perfect reference to a parameter of that name, and one + * written `6275726e` is not a reference at all, because it opens with a digit. So a bare + * literal opening with a letter is looked up, does not resolve, and is refused naming the text + * — which a person can act on, and which `0x` in front of it settles for good. That direction + * costs a refusal; the other one costs bytes nobody checks. + */ +function referencedName(value: unknown): string | undefined { + return typeof value === "string" && parseReference(value) !== undefined ? value : undefined; +} + +function withoutHexPrefix(value: string): string { + return value.startsWith("0x") || value.startsWith("0X") ? value.slice(2) : value; +} + +function reverseBytes(hex: string): string { + return (hex.match(/../g) ?? []).toReversed().join(""); +} + +function quoted(value: unknown): string { + const text = typeof value === "string" ? value : String(value); + + return `"${text.length > 24 ? `${text.slice(0, 24)}…` : text}"`; +} + +function asCount(value: unknown): bigint | undefined { + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number") { + return Number.isSafeInteger(value) ? BigInt(value) : undefined; + } + + return typeof value === "string" && /^-?\d+$/.test(value.trim()) + ? BigInt(value.trim()) + : undefined; +} diff --git a/packages/tx-manifest/src/evaluation/parameters.ts b/packages/tx-manifest/src/evaluation/parameters.ts index d2d1d6a..ad143a6 100644 --- a/packages/tx-manifest/src/evaluation/parameters.ts +++ b/packages/tx-manifest/src/evaluation/parameters.ts @@ -104,3 +104,29 @@ function walletSource(spec: Record<string, unknown>): string | undefined { return source?.type === "wallet_key" ? "key" : undefined; } + +/** + * The literal values a document states for its own parameters. + * + * A default is what "nobody supplied one" means, and some of them are constants the document + * tells every reader not to override. Reading a deployment somebody else created, a site holds + * the values that deployment was recorded with and has no reason to carry the document's own + * constants back to it — so they are read here, from the document, rather than asked for. + * + * Only literals. A computed parameter belongs to the moment its action runs, and a value from + * the wallet's own key is not a default at all; neither is a constant of the document. + */ +export function literalDefaults(action: NormalisedAction): Record<string, string> { + const declared = asRecord(action.node.params); + const defaults: Record<string, string> = {}; + + for (const [name, entry] of Object.entries(declared ?? {})) { + const spec = asRecord(entry); + + if (typeof spec?.default === "string" || typeof spec?.default === "number") { + defaults[name] = String(spec.default); + } + } + + return defaults; +} diff --git a/packages/tx-manifest/src/evaluation/plan.test.ts b/packages/tx-manifest/src/evaluation/plan.test.ts index dc94779..937ebcd 100644 --- a/packages/tx-manifest/src/evaluation/plan.test.ts +++ b/packages/tx-manifest/src/evaluation/plan.test.ts @@ -52,6 +52,26 @@ describe("planAction", () => { } }); + // The plan is where an output being change is known while the document's word about hiding + // it is still in hand, so it is where the wallet's own rule is applied. The document says + // nothing about this one and this network's answer to that is to hide; the change comes back + // published instead, so the next action can be funded from it. + test("and publishes it, saying which word it set aside to do that", () => { + const result = planAction(PAY, request({ amount_sat: 50_000, pubkey: PUBKEY })); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + const change = result.plan.outputs.find((output) => output.target.kind === "change"); + + expect(change?.blinding).toEqual({ + blinding: "open", + decidedBy: "spendable-change", + overrode: "chain", + }); + } + }); + // Amounts are base units and must survive past 2^53, which a number cannot. test("keeps an amount beyond a double's range exact", () => { const huge = "9007199254740993"; @@ -195,3 +215,84 @@ describe("planAction over expressions", () => { expect(result).toMatchObject({ ok: true }); }); }); +/** + * What an op_return's payload may name. + * + * These bytes are a record a protocol's own reader looks the action up by, and they outlive + * the transaction. The wallet's fee is a figure it models before anything is signed; the module + * that signs weighs the finished transaction and charges its own. A payload naming the fee + * would publish a number the transaction did not pay, permanently, with nothing downstream able + * to notice — so the position refuses it rather than writing it. + */ +describe("planAction over an op_return payload", () => { + function publish(value: unknown, scope: Partial<ReferenceScope> = {}) { + return planAction( + action({ + outputs: [ + { + data: { parts: [{ type: "u64", value }] }, + destination: { type: "op_return" }, + id: "marker", + }, + ], + }), + { params: {}, ...scope }, + ); + } + + test("refuses a part naming the fee even where the wallet has one", () => { + const result = publish("fee", { fee: 500n }); + + expect(result).toMatchObject({ ok: false }); + }); + + // The refusal has to name the text, because the person holding it is deciding whether to + // trust a site rather than reading their own document. + test("names the part and the text it refused", () => { + const reason = publish("fee", { fee: 500n }); + + expect(reason.ok ? "" : reason.reason).toContain("fee"); + expect(reason.ok ? "" : reason.reason).toContain("Data part 1"); + }); + + // The fee is the only form this position lost. Everything else a payload is written with is + // settled before the bytes are, and the corpus publishes its records out of the deployment. + test("publishes a field of this deployment", () => { + const result = publish("instance.LOAN_ID", { fee: 500n, instance: { LOAN_ID: 7 } }); + + expect(result).toMatchObject({ ok: true }); + + if (result.ok) { + expect(result.plan.outputs[0]?.target).toEqual({ + hex: "6a080700000000000000", + kind: "data", + }); + } + }); + + test("publishes a parameter, an argument and a bare name of the request", () => { + expect(publish("params.n", { params: { n: 1 } })).toMatchObject({ ok: true }); + expect(publish("args.n", { args: { n: 1 }, params: {} })).toMatchObject({ ok: true }); + expect(publish("n", { params: { n: 1 } })).toMatchObject({ ok: true }); + }); + + test("publishes what the wallet read about an input, which is settled before the bytes", () => { + const result = publish("p2pk_in.amount_sat", { + inputs: { p2pk_in: { amount_sat: 42_000n } }, + params: {}, + }); + + expect(result).toMatchObject({ ok: true }); + }); + + // An amount is the position where the fee belongs, and it keeps it: the difference between + // the modelled figure and the charged one lands in change, which absorbs it. + test("an amount at the same output may still name the fee", () => { + const result = planAction( + action({ outputs: [{ amount_sat: "fee", destination: "wallet", id: "out" }] }), + { fee: 500n, params: {} }, + ); + + expect(result.ok && result.plan.outputs[0]?.sats).toBe(500n); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/plan.ts b/packages/tx-manifest/src/evaluation/plan.ts index aee4432..92baf5a 100644 --- a/packages/tx-manifest/src/evaluation/plan.ts +++ b/packages/tx-manifest/src/evaluation/plan.ts @@ -67,9 +67,14 @@ export function planAction( return { ok: false, reason: `Output ${id || "(unnamed)"} ${target.reason}` }; } + // Change is flagged here rather than answered later, because this is the one place that + // knows an output is the action's own change while the document's word about hiding it is + // still in hand. The resolver publishes it and carries the word it set aside; see there + // for why that trade was made. const blinding = resolveBlinding({ declared: output.confidential, documentDefault, + ...(target.target.kind === "change" ? { change: true } : {}), ...(target.target.kind === "covenant" ? { unblindable: "covenant" as const } : target.target.kind === "data" @@ -83,10 +88,29 @@ export function planAction( continue; } - // An op_return output carries bytes rather than value. It is provably unspendable, so - // it pays nothing and nothing needs to fund it. + // An op_return output carries bytes rather than value, and almost always pays nothing. + // A document that states an amount at one is burning that amount: paying an asset to a + // provably unspendable output is how a token is destroyed, and there is no other way to + // do it. Dropping the amount would leave the transaction still holding what the action + // declared gone, which is a transaction nothing can balance. if (target.target.kind === "data") { - outputs.push({ blinding, id, sats: 0n, target: target.target }); + if (output.amount_sat === undefined) { + outputs.push({ blinding, id, sats: 0n, target: target.target }); + + continue; + } + + const burned = resolveAmount(output.amount_sat, scope, notes); + + if (!burned.ok) { + return { + ok: false, + reason: `Output ${id || "(unnamed)"} cannot be paid: ${burned.reason}`, + }; + } + + fundingSats += burned.sats; + outputs.push({ blinding, id, sats: burned.sats, target: target.target }); continue; } @@ -149,10 +173,22 @@ function resolveTarget( return { ok: false, reason: "pays somewhere this runtime does not resolve yet." }; } + // A burn states no payload at all. The output exists to hold value where nothing can spend + // it rather than to publish anything, and `6a` on its own is that script: an output whose + // first opcode is OP_RETURN cannot be spent by anyone, which is the whole of what a burn + // needs. The corpus destroys a one-of-a-kind token exactly this way, twice. + if (data === undefined) { + return { ok: true, target: { hex: "6a", kind: "data" } }; + } + // The payload is the output. An op_return with nothing in it says nothing, and a layout // the runtime could not encode is one the protocol's own reader will not recognise. + // + // Its own site rather than the expression one, whose forms are those of a check being made + // now: a validation may legitimately compare something against the fee, and these bytes are + // a record that outlives the transaction. The site states what a payload may name and why. const encoded = encodeDataParts(data, (reference) => { - const found = resolveReference(reference, "expression", scope, notes); + const found = resolveReference(reference, "dataPart", scope, notes); return found.ok ? { ok: true, value: found.value } : { ok: false, reason: found.reason }; }); diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts index 5d50308..02b94f5 100644 --- a/packages/tx-manifest/src/index.ts +++ b/packages/tx-manifest/src/index.ts @@ -21,19 +21,23 @@ export { parseLiquidProcessCtParams } from "./request/validation"; // 2. What the chain says, which only a wallet can ask for. export { + type ReadChainTip, type ReadFeeRate, type ReadTxOut, + createEsploraChainTipReader, createEsploraFeeRateReader, createEsploraTxOutReader, } from "./chain/chainRead"; export { txOutAt } from "./chain/txOut"; export { estimateFeeSats } from "./fee"; -// 3. The action, resolved into a reviewed plan or a refusal — and afterwards, the check -// that what came back spends only what was asked for. -export { type ManifestReview, isRefusal, reviewManifestAction } from "./review"; +// 3. The action, resolved into a reviewed plan or a refusal — and afterwards, the two checks +// that what came back is what was agreed to: that it spends only what was asked for, and +// that it hides exactly the amounts the document decided to hide. +export { type ManifestReview, type PlannedInput, isRefusal, reviewManifestAction } from "./review"; export type { RejectToken } from "./document/refuse"; export { guardSpentInputs } from "./chain/inputGuard"; +export { guardBlindedOutputs } from "./chain/outputGuard"; export { spentInputs } from "./chain/spentInputs"; // 4. What a person is shown, and where each value on that screen came from. @@ -55,5 +59,11 @@ export { inspectManifestDocument, } from "./document/inspect"; export type { PartialCheck } from "./document/refuse"; -export type { ConstructReport, ConstructState } from "./document/registry"; +export { + type ConstructRegistryEntry, + type ConstructReport, + type ConstructSiteKind, + type ConstructState, + describeRegistry, +} from "./document/registry"; export type { NormalisationNote } from "./document/normalise"; diff --git a/packages/tx-manifest/src/request/requirements.test.ts b/packages/tx-manifest/src/request/requirements.test.ts index 22d2335..8f065c2 100644 --- a/packages/tx-manifest/src/request/requirements.test.ts +++ b/packages/tx-manifest/src/request/requirements.test.ts @@ -191,6 +191,53 @@ describe("which actions can read a deployment", () => { expect(missing).toEqual([]); }); + /* + * An action that creates the deployment it reads. Every published constructor does this: + * it declares `create_instance`, and the fields it then refers to as `instance.*` are the + * ones it just worked out. Asking the site for that file asks it to send values the + * document computes — and a site that sent its own copy would never learn they disagreed. + */ + test("a constructor reading the deployment it creates needs no instance file", () => { + const { missing, required } = ask( + { + classes: { + Vault: { + methods: { + Open: { create_instance: { fields: { OWNER: "$params.OWNER" } }, outputs: [reader] }, + }, + }, + }, + utxo_types, + }, + "Open", + ); + + expect(required).not.toContain("instance"); + expect(missing.find((entry) => entry.part === "instance")).toBeUndefined(); + }); + + test("a constructor still needs the file for a field it reads and does not create", () => { + const { missing, required } = ask( + { + classes: { + Vault: { + methods: { + Open: { + create_instance: { fields: { KEEPER: "$params.KEEPER" } }, + outputs: [reader], + }, + }, + }, + }, + utxo_types, + }, + "Open", + ); + + expect(required).toContain("instance"); + expect(missing.find((entry) => entry.part === "instance")?.keys).toContain("OWNER"); + }); + // A free action reaching for a deployment cannot be satisfied by any request, so the // refusal names the document's fault rather than asking for a file that would not help. test("a free action reading a deployment is refused as a fault in the manifest", () => { diff --git a/packages/tx-manifest/src/request/requirements.ts b/packages/tx-manifest/src/request/requirements.ts index a58dc9d..53c5c1e 100644 --- a/packages/tx-manifest/src/request/requirements.ts +++ b/packages/tx-manifest/src/request/requirements.ts @@ -1,5 +1,5 @@ import { asRecord } from "../document/json"; -import { findAction, type NormalisedManifest } from "../document/normalise"; +import { findAction, type NormalisedAction, type NormalisedManifest } from "../document/normalise"; import { instanceReferences } from "../document/references"; import { covenantSites, namedUtxoTypes } from "../document/sites"; import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } from "./request"; @@ -70,7 +70,14 @@ export function resolveActionRequirements( }); } - const readsDeployment = instanceReferences(manifest, action); + // A field the action works out for itself is not one the request can be short of. Every + // constructor in the corpus reads back what its own `create_instance` just computed, and + // asking the site for that file asks it to send values the document derives — a site that + // sent its own copy would never find out the two disagreed. + const created = createdFields(action); + const readsDeployment = instanceReferences(manifest, action).filter( + (occurrence) => !created.has(fieldName(occurrence.text)), + ); if (readsDeployment.length > 0) { required.push("instance"); @@ -89,6 +96,7 @@ export function resolveActionRequirements( }); } else if (!request.instance) { missing.push({ + keys: [...new Set(readsDeployment.map((occurrence) => fieldName(occurrence.text)))], part: "instance", reason: "The action reads this deployment's field values.", }); @@ -149,6 +157,16 @@ function promptedParams(action: Record<string, unknown>): string[] { .map(([name]) => name); } +/** The deployment fields this action brings into existence, which it may then read. */ +function createdFields(action: NormalisedAction): Set<string> { + return new Set(Object.keys(asRecord(asRecord(action.node.create_instance)?.fields) ?? {})); +} + +/** The field one `instance.NAME` reference names. */ +function fieldName(text: string): string { + return text.slice(text.indexOf(".") + 1); +} + /** Whether the action spends a covenant UTXO, which is a lookup into the state file. */ function spendsCovenant(action: Parameters<typeof covenantSites>[0]): boolean { return covenantSites(action).some((site) => site.role === "spent"); diff --git a/packages/tx-manifest/src/review/assetFunding.test.ts b/packages/tx-manifest/src/review/assetFunding.test.ts new file mode 100644 index 0000000..be82424 --- /dev/null +++ b/packages/tx-manifest/src/review/assetFunding.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; + +import type { AssetEntry } from "../evaluation/assetLedger"; +import { fundAssets } from "./assetFunding"; +import type { SelectableUtxo } from "./coinSelection"; + +const POLICY = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const TOKEN = "aa".repeat(32); + +function utxo(amount: string, overrides: Partial<SelectableUtxo> = {}): SelectableUtxo { + return { + amount, + spendable: true, + txid: amount.padStart(64, "0"), + txOut: "00", + vout: 0, + ...overrides, + }; +} + +function entry(asset: string, needed: bigint, overrides: Partial<AssetEntry> = {}): AssetEntry { + return { asset, held: 0n, needed, ...overrides }; +} + +const CHANGE = { blinded: true, id: "token_change" }; + +function fund(entries: AssetEntry[], holdings: Record<string, SelectableUtxo[]>, feeSats = 500n) { + return fundAssets(entries, { + feeSats, + headroomSats: 0n, + holdings: (asset) => holdings[asset] ?? [], + policyAsset: POLICY, + reserved: [], + }); +} + +describe("funding an action asset by asset", () => { + test("takes each asset out of what the wallet holds in that one", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n, { change: CHANGE })], { + [POLICY]: [utxo("900")], + [TOKEN]: [utxo("1500")], + }); + + expect( + result.ok && result.funded.map((funded) => funded.selected.map((one) => one.amount)), + ).toEqual([["900"], ["1500"]]); + }); + + // The fee is charged in one asset and is added to that one alone. A second asset picking up + // a second fee would make the wallet demand money nobody is asking for. + test("adds the fee to the network's own asset and to no other", () => { + const short = fund([entry(POLICY, 0n)], { [POLICY]: [utxo("400")] }, 500n); + const exact = fund([entry(TOKEN, 400n, { change: CHANGE })], { [TOKEN]: [utxo("400")] }, 500n); + + expect(short.ok).toBe(false); + expect(exact.ok).toBe(true); + }); + + test("returns the exact surplus of every asset but the network's own", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n, { change: CHANGE })], { + [POLICY]: [utxo("9000")], + [TOKEN]: [utxo("1500")], + }); + + expect(result.ok && result.funded.map((funded) => funded.changeSats)).toEqual([0n, 500n]); + }); + + // What a covenant already holds is what the wallet does not have to find. Netting it per + // asset is what lets an action pay out of the covenant it spends. + test("counts what the transaction already brings before asking the wallet for anything", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n, { held: 1000n })], { + [POLICY]: [utxo("9000")], + }); + + expect(result.ok && result.funded[1]?.selected).toEqual([]); + }); + + test("and asks for only the difference when it brings some of it", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n, { change: CHANGE, held: 600n })], { + [POLICY]: [utxo("9000")], + [TOKEN]: [utxo("500"), utxo("50")], + }); + + // Four hundred short, and the largest single output covers it. A wallet that had ignored + // what the covenant holds would have taken both and still been short. + expect(result.ok && result.funded[1]?.selected.map((one) => one.amount)).toEqual(["500"]); + expect(result.ok && result.funded[1]?.changeSats).toBe(100n); + }); +}); + +describe("when one asset cannot be funded", () => { + test("the refusal names the asset and what the account holds of it", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n)], { + [POLICY]: [utxo("9000")], + [TOKEN]: [utxo("40")], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain(TOKEN); + expect(result.ok ? "" : result.reason).toContain("40"); + expect(result.ok ? "" : result.reject).toBe("shortfall"); + }); + + // The refusal for another asset never mentions the fee, because no fee is charged in it. + test("and never explains a shortfall in one asset by the fee charged in another", () => { + const result = fund([entry(TOKEN, 1000n)], { [TOKEN]: [utxo("40")] }); + + expect(result.ok ? "" : result.reason).not.toContain("fee"); + }); + + test("a confidential holding is named as held back rather than counted", () => { + const result = fund([entry(TOKEN, 1000n)], { + [TOKEN]: [utxo("5000", { confidential: true })], + }); + + expect(result.ok ? "" : result.reason).toContain("5000"); + expect(result.ok ? "" : result.reason).toContain("unblinded address"); + }); + + // Surplus with nowhere declared to go is value the transaction would destroy, so it is + // refused rather than built. The network's own asset is exempt: its surplus is the fee's. + test("a surplus the document declares no change output for is refused", () => { + const result = fund([entry(TOKEN, 1000n)], { [TOKEN]: [utxo("1500")] }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("500"); + expect(result.ok ? "" : result.reject).toBe("document-fault"); + }); + + test("and the same surplus in the network's own asset is not, because the fee takes it", () => { + const result = fund([entry(POLICY, 1000n)], { [POLICY]: [utxo("9000")] }); + + expect(result.ok).toBe(true); + }); +}); + +describe("an output already committed to for an issuance", () => { + const reserved = utxo("700"); + + function withReserved(entries: AssetEntry[], holdings: Record<string, SelectableUtxo[]>) { + return fundAssets(entries, { + feeSats: 0n, + headroomSats: 0n, + holdings: (asset) => holdings[asset] ?? [], + policyAsset: POLICY, + reserved: [{ asset: TOKEN, utxo: reserved }], + }); + } + + // It is an input of this transaction whether or not the arithmetic would have chosen it, so + // what it brings counts and it is never chosen twice. + test("counts towards its own asset and is not selected again", () => { + const result = withReserved([entry(TOKEN, 1000n, { change: CHANGE })], { + [TOKEN]: [reserved, utxo("400")], + }); + + expect(result.ok && result.funded[0]?.selected.map((one) => one.amount)).toEqual([ + "700", + "400", + ]); + expect(result.ok && result.funded[0]?.changeSats).toBe(100n); + }); + + test("and comes first, because the asset it mints is a statement about that output", () => { + const result = withReserved([entry(TOKEN, 2000n, { change: CHANGE })], { + [TOKEN]: [reserved, utxo("5000")], + }); + + expect(result.ok && result.funded[0]?.selected[0]).toBe(reserved); + }); +}); diff --git a/packages/tx-manifest/src/review/assetFunding.ts b/packages/tx-manifest/src/review/assetFunding.ts new file mode 100644 index 0000000..16e6d53 --- /dev/null +++ b/packages/tx-manifest/src/review/assetFunding.ts @@ -0,0 +1,143 @@ +import type { AssetEntry } from "../evaluation/assetLedger"; +import { type SelectableUtxo, selectCoins, toSats } from "./coinSelection"; + +/** The wallet's spendable outputs in one asset, asked for by the id the chain knows it as. */ +export type AssetHoldings = (asset: string) => SelectableUtxo[]; + +/** What one asset ended up funded by, and what comes back in it. */ +export type FundedAsset = { + asset: string; + /** + * What returns to the wallet in this asset as an output the wallet builds itself. + * + * Zero for the asset the network charges its fees in: that surplus is the fee's to take + * from, and what is left of it is change the signing module works out from the finished + * weight. Every other asset's surplus is exact here, because nothing takes a bite out of it. + */ + changeSats: bigint; + /** The wallet's own outputs paying for this asset, in the order they will be added. */ + selected: SelectableUtxo[]; +}; + +export type AssetFundingResult = + | { funded: FundedAsset[]; ok: true } + | { ok: false; reason: string; reject: "document-fault" | "shortfall" }; + +export type AssetFundingContext = { + /** What the wallet worked the fee out to be, which only the network's own asset pays. */ + feeSats: bigint; + /** What selection adds on top for a fee that is not final until the transaction is weighed. */ + headroomSats: bigint; + holdings: AssetHoldings; + policyAsset: string; + /** + * Outputs already committed to before funding was worked out, with the asset they are in. + * + * An issuance derives its asset id from the output its input spends, so that output is + * chosen before anything else — and it is an input of this transaction whether or not the + * arithmetic below would have picked it. Counting it twice would fund the action twice; not + * counting it would fund it once too little. + */ + reserved: { asset: string; utxo: SelectableUtxo }[]; +}; + +/** + * Funds every asset an action moves, each out of what the wallet holds in that asset. + * + * The rule is one sentence applied per asset: what the outputs cost, less what the transaction + * already brings, is what the wallet has to find — and the network's own asset carries the fee + * on top because the fee is charged in it and in nothing else. A second asset never becomes a + * second fee. + * + * Where an asset comes up short the refusal names it. A person told "you do not have enough" by + * a wallet holding plenty of money is being told something true about an asset they were not + * thinking about, and which one it is, is the whole of the answer. + */ +export function fundAssets( + entries: AssetEntry[], + context: AssetFundingContext, +): AssetFundingResult { + const policyAsset = context.policyAsset.trim().toLowerCase(); + const funded: FundedAsset[] = []; + + for (const entry of entries) { + const isPolicy = entry.asset === policyAsset; + const reserved = context.reserved + .filter((held) => held.asset === entry.asset) + .map((held) => held.utxo); + const committed = new Set(reserved.map((utxo) => `${utxo.txid}:${utxo.vout}`)); + const brought = reserved.reduce((total, utxo) => total + toSats(utxo.amount), entry.held); + const fee = isPolicy ? context.feeSats : 0n; + const outstanding = entry.needed + fee - brought; + let selected = reserved; + let total = brought; + + if (outstanding > 0n) { + // By outpoint rather than by identity: an output already committed to is the same + // output however many times the wallet describes it, and one spent twice is not a + // transaction at all. + const pool = context + .holdings(entry.asset) + .filter((utxo) => !committed.has(`${utxo.txid}:${utxo.vout}`)); + const selection = selectCoins(pool, outstanding, isPolicy ? context.headroomSats : 0n); + + if (!selection.ok) { + return { + ok: false, + reason: isPolicy ? selection.reason : shortOf(entry.asset, outstanding, pool), + reject: "shortfall", + }; + } + + selected = [...reserved, ...selection.selected]; + total = brought + selection.totalSats; + } + + const surplus = total - entry.needed - fee; + + // The asset the network charges in is the signing module's to balance: it takes the fee + // out of this surplus and returns what is left. Any other asset has to be balanced here, + // exactly, and an asset with more coming in than going out and nowhere declared to put + // the difference is an action this wallet cannot build without destroying value. + if (!isPolicy && surplus > 0n && !entry.change) { + return { + ok: false, + reason: + `This action leaves ${surplus} of ${entry.asset} over, and declares no change ` + + "output to return it to. Building it would destroy that amount.", + reject: "document-fault", + }; + } + + funded.push({ + asset: entry.asset, + changeSats: isPolicy ? 0n : surplus, + selected, + }); + } + + return { funded, ok: true }; +} + +/** + * Why the wallet is short of one asset, said in terms of that asset. + * + * Written here rather than taken from selection because selection's own sentence names the fee, + * and the fee is charged in one asset only. Telling someone they cannot pay the fee in a token + * would be a wallet explaining its refusal with something that was never true. + */ +function shortOf(asset: string, needed: bigint, pool: SelectableUtxo[]): string { + const usable = pool + .filter((utxo) => utxo.spendable && !utxo.confidential) + .reduce((sum, utxo) => sum + toSats(utxo.amount), 0n); + const withheld = pool + .filter((utxo) => utxo.spendable && utxo.confidential) + .reduce((sum, utxo) => sum + toSats(utxo.amount), 0n); + + return ( + `This action pays ${needed} of ${asset}, and this account holds ${usable} of it.` + + (withheld > 0n + ? ` A further ${withheld} is in confidential outputs, which a contract action cannot spend — send it to this account's unblinded address to use it.` + : "") + ); +} diff --git a/packages/tx-manifest/src/review/covenantParams.test.ts b/packages/tx-manifest/src/review/covenantParams.test.ts new file mode 100644 index 0000000..11ab218 --- /dev/null +++ b/packages/tx-manifest/src/review/covenantParams.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; + +import dexManifest from "../__fixtures__/current/dex.manifest.json"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import { isRefusal, reviewManifestAction } from "./index"; + +/** + * A published protocol whose covenants are parameterised by asset ids and amounts, reviewed. + * + * The swap in `dex.manifest.json` is the corpus's clearest case of the two positions a + * covenant's parameters can be declared at. `MakeOffer` takes the offer's terms as its own + * parameters and locks them into a covenant. `Settle` takes no parameters at all and compiles + * the same covenant out of the deployment `MakeOffer` created. Until the types at both + * positions could be read and encoded, the first was refused for a type nobody had mapped and + * the second for a name nobody could resolve — so neither action reviewed end to end, and no + * address existed to compare against the chain. + * + * The contracts are compiled by a substitute here. This protocol's sources are not published + * alongside it — in production they arrive with the request — so what an address of it comes + * out as cannot be checked against anything. What is checked is what the compiler is asked + * for. A covenant whose address is checked against a value the chain holds is + * `covenants/deployedCovenant.test.ts` and its other half in the wallet's smplx adapter. + */ + +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const ASSET_A = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec8ef5b4d5"; +const ASSET_B = "aa0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec8ef5b4d5"; +const MAKER_KEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const MAKER_SPK_HASH = "cc".repeat(32); +const DERIVED_SCRIPT = `5120${"11".repeat(32)}`; +const WALLET_SCRIPT = `0014${"11".repeat(20)}`; + +const SOURCES = { + "./maker_payout.simf": "fn main() { }", + "./tessera.simf": "fn main() { }", +}; + +/** The offer's terms, as `MakeOffer` declares them and as the deployment records them. */ +const TERMS = { + AMOUNT_B: "500", + ASSET_B, + MAKER_PUB_KEY: MAKER_KEY, + MAX_FEE: "5000", + OFFER_AMOUNT: "1000", + OFFER_ASSET_ID: ASSET_A, + TIMEOUT: "900000", +}; + +function settle(request: ParsedLiquidProcessCtParams) { + const compiled: { argumentsJson: string; source: string }[] = []; + const holdings = [ + { amount: "100000000", spendable: true, txid: "1".repeat(64), txOut: "00", vout: 0 }, + ]; + + return { + compiled, + review: reviewManifestAction(request, { + accountLabel: "liquid:testnet account 0", + compile: (input) => { + compiled.push({ argumentsJson: input.argumentsJson, source: input.source }); + + return { address: "ex1p_derived", scriptPubKeyHex: DERIVED_SCRIPT }; + }, + compilerVersion: "0.6.0", + fundingUtxos: holdings, + holdingsOf: () => holdings, + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: async () => ({ + amountSats: "1000", + rawAssetId: ASSET_A, + scriptPubKeyHex: DERIVED_SCRIPT, + txOutHex: "00", + }), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }), + }; +} + +/** Every argument the wiring named, flattened out of whichever compile carried it. */ +function argumentFor(calls: { argumentsJson: string }[], name: string): unknown { + for (const call of calls) { + const parsed = JSON.parse(call.argumentsJson) as Record<string, unknown>; + + if (name in parsed) { + return parsed[name]; + } + } + + return undefined; +} + +describe("an offer whose terms the request supplies", () => { + const request = { + action: "MakeOffer", + broadcast: false, + contractSources: SOURCES, + manifest: dexManifest as unknown as Record<string, unknown>, + params: TERMS, + } as unknown as ParsedLiquidProcessCtParams; + + test("is reviewed rather than refused for a type nobody mapped", async () => { + const { review } = settle(request); + + expect(isRefusal(await review)).toBe(false); + }); + + test("and its covenant is built from the terms, each at the width it was declared", async () => { + const { compiled, review } = settle(request); + + await review; + + expect(argumentFor(compiled, "AMOUNT_B")).toEqual({ type: "u64", value: "500" }); + expect(argumentFor(compiled, "TIMEOUT")).toEqual({ type: "u32", value: "900000" }); + expect(argumentFor(compiled, "MAX_FEE")).toEqual({ type: "u64", value: "5000" }); + }); + + test("with the asset it wants paid in turned round the way a covenant reads one", async () => { + const { compiled, review } = settle(request); + + await review; + + const committed = (ASSET_B.match(/../g) ?? []).toReversed().join(""); + + expect(argumentFor(compiled, "ASSET_B")).toEqual({ type: "u256", value: `0x${committed}` }); + }); +}); + +describe("the same offer, filled by someone who supplies nothing", () => { + const request = { + action: "Settle", + broadcast: false, + contractSources: SOURCES, + instance: { + instance: { + class: "tessera_offer_contract", + fields: { ...TERMS, MAKER_SPK: MAKER_SPK_HASH }, + }, + }, + manifest: dexManifest as unknown as Record<string, unknown>, + params: {}, + state: { utxos: [{ txid: "b".repeat(64), utxo_type: "tessera_offer", vout: 0 }] }, + } as unknown as ParsedLiquidProcessCtParams; + + test("is reviewed, where before no name in its wiring could be resolved", async () => { + const { review } = settle(request); + + expect(isRefusal(await review)).toBe(false); + }); + + test("and rebuilds the offer covenant from the deployment's own fields", async () => { + const { compiled, review } = settle(request); + + await review; + + expect(argumentFor(compiled, "AMOUNT_B")).toEqual({ type: "u64", value: "500" }); + expect(argumentFor(compiled, "MAKER_SPK")).toEqual({ + type: "u256", + value: `0x${MAKER_SPK_HASH}`, + }); + expect(argumentFor(compiled, "TIMEOUT")).toEqual({ type: "u32", value: "900000" }); + }); + + /** + * The maker's payout covenant is a second one in the same action, wired to a key rather + * than to the terms. It is here because the two are compiled in the order the document + * declares them, and an action that only worked for its first covenant would still look + * like it worked. + */ + test("and the maker's payout covenant beside it, from the key the deployment records", async () => { + const { compiled, review } = settle(request); + + await review; + + expect(compiled.length).toBeGreaterThan(1); + expect(argumentFor(compiled, "PUB_KEY")).toEqual({ + type: "Pubkey", + value: `0x${MAKER_KEY}`, + }); + }); +}); + +describe("what it still refuses", () => { + test("an amount too large for the width the document declared it at", async () => { + const { review } = settle({ + action: "MakeOffer", + broadcast: false, + contractSources: SOURCES, + manifest: dexManifest as unknown as Record<string, unknown>, + params: { ...TERMS, TIMEOUT: "4294967296" }, + } as unknown as ParsedLiquidProcessCtParams); + + const result = await review; + + expect(isRefusal(result)).toBe(true); + expect(isRefusal(result) ? result.reason : "").toContain("0 to 4294967295"); + }); + + test("an asset id that is not thirty-two bytes, before any address is derived", async () => { + const { compiled, review } = settle({ + action: "MakeOffer", + broadcast: false, + contractSources: SOURCES, + manifest: dexManifest as unknown as Record<string, unknown>, + params: { ...TERMS, ASSET_B: "6f0279e9" }, + } as unknown as ParsedLiquidProcessCtParams); + + expect(isRefusal(await review)).toBe(true); + expect(compiled).toHaveLength(0); + }); +}); diff --git a/packages/tx-manifest/src/review/createdDeployment.test.ts b/packages/tx-manifest/src/review/createdDeployment.test.ts new file mode 100644 index 0000000..9108e64 --- /dev/null +++ b/packages/tx-manifest/src/review/createdDeployment.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; + +import dexManifest from "../__fixtures__/current/dex.manifest.json"; +import lastWillManifest from "../__fixtures__/current/last_will.manifest.json"; +import lendingManifest from "../__fixtures__/current/lending_v3.manifest.json"; +import { isRefusal, reviewManifestAction } from "./index"; + +/** + * An action that brings a deployment into existence, reviewed end to end. + * + * Everything else this package does starts from a contract that already exists: the wallet + * rebuilds it, reads what is at its outpoint, and refuses when the two disagree. A constructor + * has none of that. There is no deployment to read its fields from, nothing on chain to compare + * a derived address against, and — where the action issues an asset — half the deployment's + * fields are functions of an output the wallet has not chosen yet. + * + * Three published protocols declare a constructor and all three are exercised here, because the + * point is a rule of the format rather than an accommodation for one document. + * + * The contracts are compiled by a substitute, as everywhere in this package. What the real + * compiler makes of the same deployment is `adapters/smplx/createdDeployment.test.ts`. + */ + +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const DERIVED_SCRIPT = `5120${"11".repeat(32)}`; +const FUNDING = [ + { amount: "100000000", spendable: true, txid: "1".repeat(64), txOut: "00", vout: 0 }, +]; + +/** Every contract path a document names, stubbed — no case here needs a real compiler. */ +function sourcesOf(document: unknown): Record<string, string> { + const paths = new Set<string>(); + + JSON.stringify(document, (key, value) => + (key === "source" || key === "simf") && typeof value === "string" + ? (paths.add(value), value) + : value, + ); + + return Object.fromEntries([...paths].map((path) => [path, `fn main() { ${path} }`])); +} + +async function review(document: unknown, action: string, params: Record<string, unknown>) { + return reviewManifestAction( + { + action, + broadcast: false, + contractSources: sourcesOf(document), + manifest: document as Record<string, unknown>, + params, + } as never, + { + accountLabel: "liquid account 0", + compile: () => ({ address: "ex1p_derived", scriptPubKeyHex: DERIVED_SCRIPT }), + compilerVersion: "0.6.0", + fundingUtxos: FUNDING, + holdingsOf: () => FUNDING, + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: async () => ({ + amountSats: "1", + rawAssetId: POLICY_ASSET, + scriptPubKeyHex: DERIVED_SCRIPT, + txOutHex: "00", + }), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: `0014${"11".repeat(20)}`, + } as never, + ); +} + +describe("an action that creates a deployment", () => { + test("reviews with no deployment to read, and reports the one it creates", async () => { + const result = await review(lastWillManifest, "Fund", { + COLD_PUB_KEY: PUBKEY, + HOT_PUB_KEY: PUBKEY, + INHERITOR_PUB_KEY: PUBKEY, + amount_sat: 100_000, + }); + + expect(isRefusal(result)).toBe(false); + + if (isRefusal(result)) { + return; + } + + // Every field of the new deployment, including the one nobody typed: the document states + // a default and the deployment records what the default came to. + expect(result.createdInstance?.fields).toEqual({ + COLD_PUB_KEY: PUBKEY, + HOT_PUB_KEY: PUBKEY, + INHERIT_BLOCKS: "25920", + INHERITOR_PUB_KEY: PUBKEY, + }); + }); + + /** + * What a person is shown for a contract with no history. + * + * Not "unverified", which is what a check that failed would be, and not "verified", which + * would claim a comparison nobody could make. The wallet derived the address itself from the + * contract source and the deployment it just worked out — that is the whole protection here, + * and saying so is different from saying a check passed. + */ + test("reports a covenant it creates as one with nothing yet to compare against", async () => { + const result = await review(lastWillManifest, "Fund", { + COLD_PUB_KEY: PUBKEY, + HOT_PUB_KEY: PUBKEY, + INHERITOR_PUB_KEY: PUBKEY, + amount_sat: 100_000, + }); + + expect(isRefusal(result) ? [] : result.covenants).toEqual([ + { + address: "ex1p_derived", + role: "created", + scriptPubKeyHex: DERIVED_SCRIPT, + utxoType: "last_will", + verified: "not-yet-on-chain", + }, + ]); + }); + + test("works out a deployment field the document computes from the rest", async () => { + const result = await review(dexManifest, "MakeOffer", { + AMOUNT_B: "5000", + ASSET_B: "b".repeat(64), + MAKER_PUB_KEY: PUBKEY, + OFFER_AMOUNT: "1000", + OFFER_ASSET_ID: POLICY_ASSET, + TIMEOUT: "900000", + }); + + expect(isRefusal(result)).toBe(false); + + if (isRefusal(result)) { + return; + } + + // A covenant hash, which is the one field of this deployment nothing could supply. + expect(result.createdInstance?.fields.MAKER_SPK).toHaveLength(64); + expect(result.createdInstance?.rounds).toBeGreaterThan(0); + }); + + test("carries no deployment for an action that only spends what exists", async () => { + const result = await review(dexManifest, "MakeOffer", { + AMOUNT_B: "5000", + ASSET_B: "b".repeat(64), + MAKER_PUB_KEY: PUBKEY, + OFFER_AMOUNT: "1000", + OFFER_ASSET_ID: POLICY_ASSET, + TIMEOUT: "900000", + }); + const spending = await review(lastWillManifest, "Refresh", {}); + + expect(isRefusal(result) ? undefined : result.createdInstance).toBeDefined(); + expect(isRefusal(spending) ? undefined : spending.createdInstance).toBeUndefined(); + }); +}); + +/** + * The ordering this needed, stated against the document that needs it. + * + * The factory's own creation reads a field of the deployment it is creating, and that field is + * the asset the action itself issues — so it cannot exist until the transaction does. Working + * the deployment out before the inputs were resolved refused it outright. The action still stops + * further on, and this pins where: a metadata part naming a value in the deployment namespace + * that no class in the document declares as a field. That is the document's own error, and it + * is the only one of its kind in the seven published manifests. + */ +describe("a deployment whose fields the action's own issuance produces", () => { + test("is no longer refused for a field the transaction had not created yet", async () => { + const result = await review(lendingManifest, "CreateFactory", {}); + + expect(isRefusal(result)).toBe(true); + + if (!isRefusal(result)) { + return; + } + + expect(result.reason).not.toContain("Field FACTORY_ASSET_ID"); + expect(result.reason).toContain('instance carries no "FACTORY_PROGRAM_ID"'); + }); +}); diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts index 512e40a..e6b25e0 100644 --- a/packages/tx-manifest/src/review/index.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -210,9 +210,16 @@ describe("reviewManifestAction", () => { if (!isRefusal(result)) { expect(result.outputs).toContainEqual({ + // Paid in the asset the document states, which here is the network's own. The + // builder is told rather than left to assume, so a document paying in anything + // else reaches the chain in that asset instead of quietly in this one. + asset: POLICY_ASSET, // The document says nothing about hiding this one, and on this network silence // means hidden. blinded: true, + // Which is a different sentence from the protocol having asked, and is carried + // so that a person can be told which of the two they are looking at. + decidedBy: "chain", id: "received_out", sats: 77_000n, scriptPubKeyHex: WALLET_SCRIPT, @@ -596,6 +603,17 @@ function issuingManifest() { inputs[0]!.issuance = { asset_amount_sat: 2, inflation_amount_sat: 0, kind: "new" }; inputs[0]!.on_resolved = { set: { "instance.MINTED_ASSET": "asset" } }; + // Where the created units go. An issuance that mints two units and declares no output + // holding them is a transaction the network will not balance, so the corpus never writes + // one — `CreateFactory` pays its minted unit straight back to the wallet, and so does this. + outputs.push({ + amount_sat: 2, + asset: "instance.MINTED_ASSET", + confidential: false, + description: "The units this action created, returned to the wallet that made them.", + destination: "wallet", + id: "minted_out", + }); outputs.push({ data: { parts: [{ type: "bytes", value: "instance.MINTED_ASSET" }] }, description: "The asset this action created, published for whoever indexes it.", @@ -678,6 +696,22 @@ describe("an input that creates an asset", () => { expect(marker?.scriptPubKeyHex).not.toBe(WALLET_SCRIPT); }); + // The units an issuance mints are brought by the transaction, not found in the wallet. This + // wallet holds nothing in the minted asset — there is no way it could, the asset does not + // exist until this transaction does — so an accounting that looked for them would refuse + // every issuance there has ever been. + test("does not go looking in the wallet for the units it is about to mint", async () => { + const result = await issuingReview(); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect( + result.movements.find((movement) => movement.asset === result.issuances[0]?.asset), + ).toMatchObject({ sats: 2n }); + } + }); + test("refuses when the wallet has no output to derive an asset from", async () => { const result = await reviewManifestAction(request({ manifest: issuingManifest() }), { ...deps, @@ -693,29 +727,29 @@ describe("an input that creates an asset", () => { }); }); -describe("a covenant told which branch to run", () => { - /** - * The p2pk spend, with a branch selector grafted onto its covenant input. - * - * The declaration is the shape every published protocol writes: a SimplicityHL type and a - * literal, both stated by the document. p2pk's own contract has one branch, so the value - * here proves the path rather than the protocol. - */ - function branchingManifest() { - const document = structuredClone(p2pkManifest) as unknown as Record<string, unknown>; - const actions = document.actions as Record<string, Record<string, unknown>>; - const inputs = actions.Receive!.inputs as Record<string, unknown>[]; - const witnesses = inputs[0]!.witnesses as Record<string, unknown>; - - witnesses.PATH = { - simplicity_type: "Either<u32, u32>", - type: "simplicityhl", - value: "Left(instance.CHOSEN)", - }; +/** + * The p2pk spend, with a branch selector grafted onto its covenant input. + * + * The declaration is the shape every published protocol writes: a SimplicityHL type and a + * literal, both stated by the document. p2pk's own contract has one branch, so the value + * here proves the path rather than the protocol. + */ +function branchingManifest() { + const document = structuredClone(p2pkManifest) as unknown as Record<string, unknown>; + const actions = document.actions as Record<string, Record<string, unknown>>; + const inputs = actions.Receive!.inputs as Record<string, unknown>[]; + const witnesses = inputs[0]!.witnesses as Record<string, unknown>; - return document; - } + witnesses.PATH = { + simplicity_type: "Either<u32, u32>", + type: "simplicityhl", + value: "Left(instance.CHOSEN)", + }; + + return document; +} +describe("a covenant told which branch to run", () => { test("carries the stated value on the input it belongs to, resolved", async () => { const result = await reviewManifestAction( { @@ -752,3 +786,207 @@ describe("a covenant told which branch to run", () => { expect(isRefusal(result)).toBe(true); }); }); + +/** + * Whose word hid an amount, followed all the way to the sentence a person reads. + * + * The wallet works this out while it reads the document and the builder never needs it: an + * output is hidden or it is not, and a blinding key is the whole of what crosses. So the word + * behind the decision was computed and dropped, and every hidden amount reached a person — when + * it reached them at all — with no way to tell "this protocol asked" from "nobody said, and this + * network's own answer is to hide". + * + * Each branch of the precedence is driven from a real document here rather than from the + * resolver, because the resolver agreeing with itself is not the claim. The claim is that the + * answer survives planning, review and the confirmation model. + */ +/** The same document with one output's own word about hiding changed, or removed. */ +function saying(confidential: boolean | undefined): Record<string, unknown> { + const document = structuredClone(p2pkManifest) as unknown as Record<string, unknown>; + const output = ( + document as unknown as { + actions: { Receive: { outputs: Record<string, unknown>[] } }; + } + ).actions.Receive.outputs[0]!; + + if (confidential === undefined) { + delete output.confidential; + } else { + output.confidential = confidential; + } + + return document; +} + +describe("what a person is told about a hidden amount", () => { + async function shown(manifest: Record<string, unknown>) { + const result = await reviewManifestAction( + { ...spendRequest(oneCovenantUtxo), manifest }, + { ...deps, readTxOut: readTxOut(DERIVED_SCRIPT) }, + ); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + return result.confirmation.hiddenAmounts.map((hidden) => ({ + decidedBy: hidden.decidedBy.value, + id: hidden.id.value, + })); + } + + test("when the protocol asked for it outright", async () => { + expect(await shown(saying(true))).toContainEqual({ + decidedBy: "this protocol asks for it to be hidden", + id: "received_out", + }); + }); + + // The step that makes silence a decision, and the one a person is least likely to have + // expected: this is every hidden output in the published corpus. + test("when nobody said anything and this network's own answer is to hide", async () => { + expect(await shown(saying(undefined))).toContainEqual({ + decidedBy: "nothing says otherwise and this network hides an output by default", + id: "received_out", + }); + }); + + test("when the document states it for the whole file", async () => { + expect(await shown({ ...saying(undefined), confidential_outputs: true })).toContainEqual({ + decidedBy: "this protocol hides its outputs by default", + id: "received_out", + }); + }); + + // An amount in the open is not on this list at all. The screen says what is hidden, so an + // output nobody hid has nothing to say there. + test("and says nothing about an output the protocol leaves in the open", async () => { + expect(await shown(saying(false)).then((rows) => rows.map((row) => row.id))).not.toContain( + "received_out", + ); + }); + + // The change was on this list until the wallet began publishing it, and it was the entry a + // person was least likely to have expected. It is not hidden now, so it is not here. + test("and no longer the change it returns, which this wallet publishes", async () => { + expect(await shown(saying(undefined)).then((rows) => rows.map((row) => row.id))).not.toContain( + "change", + ); + }); +}); + +/** + * The one place this wallet answers over the format instead of under it. + * + * The format says an output's silence about confidentiality is itself a decision and that on + * this network the decision is to hide. That holds everywhere in this runtime except a contract + * action's own change, which is published so the money returns in a form the next action can be + * funded from — a contract action can be funded only by outputs that hide nothing, so change + * returned hidden is money the next action cannot reach. + * + * The amount is on the chain as a result, and that is the price. What the wallet owes in return + * is to say so, in the same terms it already uses for every amount it hides, naming the word it + * set aside rather than overriding a protocol quietly. + */ +/** The same document with the action's change output speaking, silent, or gone entirely. */ +function changeSaying(confidential: boolean | undefined | "absent"): Record<string, unknown> { + const document = structuredClone(p2pkManifest) as unknown as Record<string, unknown>; + const outputs = ( + document as unknown as { + actions: { Receive: { outputs: Record<string, unknown>[] } }; + } + ).actions.Receive.outputs; + + if (confidential === "absent") { + outputs.pop(); + + return document; + } + + const output = outputs[1]!; + + if (confidential === undefined) { + delete output.confidential; + } else { + output.confidential = confidential; + } + + return document; +} + +describe("what a person is told about an amount this wallet published", () => { + async function reviewed(manifest: Record<string, unknown>) { + const result = await reviewManifestAction( + { ...spendRequest(oneCovenantUtxo), manifest }, + { ...deps, readTxOut: readTxOut(DERIVED_SCRIPT) }, + ); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + return result; + } + + async function published(manifest: Record<string, unknown>) { + return (await reviewed(manifest)).confirmation.publishedAmounts.map((row) => ({ + id: row.id.value, + reason: row.reason.value, + })); + } + + // Every change output in the published corpus is this case: not one document says anything + // about its change, and this network answers that silence by hiding. + test("naming the network's own default, which is what the whole corpus leaves it to", async () => { + expect(await published(changeSaying(undefined))).toEqual([ + { + id: "change", + reason: + "nothing says otherwise and this network hides an output by default, and this " + + "wallet publishes it anyway so your next action can spend it", + }, + ]); + }); + + // A different sentence for a different word, which is the whole reason the word is carried. + // A protocol that asked outright and a protocol that never spoke build the same output here + // and are not the same thing to someone deciding whether to trust the site. + test("naming the protocol's own word when it asked for hidden change outright", async () => { + expect(await published(changeSaying(true))).toEqual([ + { + id: "change", + reason: + "this protocol asks for it to be hidden, and this wallet publishes it anyway so " + + "your next action can spend it", + }, + ]); + }); + + // The deviation fires only where the format would have hidden. A protocol asking for its + // change in the open is agreed with rather than overridden, so there is nothing to disclose. + test("and says nothing when the protocol asked for open change itself", async () => { + expect(await published(changeSaying(false))).toEqual([]); + }); + + /** + * The second inconsistency, dissolved rather than chosen between. + * + * One silence used to get two answers. A declared change output saying nothing resolved + * hidden, while an action declaring no change output at all fell through to open — and the + * signing module appends change either way, so the two built opposite outputs from the same + * absence of a word. Three actions across two published protocols reach the second case. + * + * Both are a contract action's own change, so both are published now, and the person is told + * the same sentence for both. There was nothing left to decide. + */ + test("the same, for an action that declares no change output at all", async () => { + expect(await published(changeSaying("absent"))).toEqual( + await published(changeSaying(undefined)), + ); + }); + + test("and the transaction is built the same way in both", async () => { + expect((await reviewed(changeSaying("absent"))).changeBlinded).toBe(false); + expect((await reviewed(changeSaying(undefined))).changeBlinded).toBe(false); + }); +}); diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index 3304f08..fb5aba7 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -1,13 +1,20 @@ -import type { ReadFeeRate, ReadTxOut } from "../chain/chainRead"; +import type { ReadChainTip, ReadFeeRate, ReadTxOut } from "../chain/chainRead"; import { type ConfirmationModel, confirmationModel } from "../confirmation"; import { resolveComputedParams } from "../covenants/computed"; import { type CompileCovenant, + type ContractParamTypesOf, covenantMatchesChain, deriveCovenantAddress, } from "../covenants/covenant"; import { type CompileScriptPubKey, covenantHashFrom } from "../covenants/covenantHash"; -import { createsInstance, resolveCreatedInstance } from "../covenants/instance"; +import { declaredParamTypes } from "../covenants/declaredTypes"; +import { completeSuppliedInstance } from "../covenants/instance"; +import { + type CreatedInstance, + createsInstance, + resolveCreatedInstance, +} from "../covenants/instance"; import { asArray, asRecord } from "../document/json"; import { findAction, @@ -20,6 +27,8 @@ import type { ReferenceScope } from "../document/references"; import { buildMode, type RejectToken, refuseUnsupported } from "../document/refuse"; import { type ConstructFinding, ignored, inspectConstructs } from "../document/registry"; import { covenantSites } from "../document/sites"; +import { assetLedger, type HeldValue, resolveAsset } from "../evaluation/assetLedger"; +import type { BlindingDecision, BlindingWord } from "../evaluation/blinding"; import { actionHook, inputHook, @@ -27,6 +36,7 @@ import { runHook, withHookValues, } from "../evaluation/hooks"; +import { type PlaceableInput, placeInputs } from "../evaluation/inputOrder"; import { type InputRule, resolveInputRules } from "../evaluation/inputRules"; import { declaredIssuance, @@ -42,7 +52,8 @@ import { type StaticWitness, resolveStaticWitnesses } from "../evaluation/witnes import { estimateFeeSats } from "../fee"; import type { ParsedLiquidProcessCtParams } from "../request/request"; import { resolveActionRequirements } from "../request/requirements"; -import { type CoinSelection, type SelectableUtxo, selectCoins, toSats } from "./coinSelection"; +import { type AssetHoldings, fundAssets } from "./assetFunding"; +import { type SelectableUtxo, toSats } from "./coinSelection"; /** * What the wallet established for itself about one covenant this action touches. @@ -68,6 +79,15 @@ export type CovenantFinding = { */ export type ReviewedCovenantInput = { argumentsJson: string; + /** + * The extra taproot leaves and the build mode this covenant was verified under. + * + * Carried because the module that spends it compiles the contract again, and a compile that + * differs in either produces a different script — which the covenant's own execution then + * rejects, after a person has approved a transaction the wallet had already checked. + */ + extraLeavesJson: string; + includeDebugSymbols: boolean; /** The manifest's id for the input, so what the action requires of it can be found. */ id: string; /** @@ -93,8 +113,27 @@ export type ReviewedCovenantInput = { witnessValues?: StaticWitness[]; }; +/** + * One input of the transaction, in the place the wallet is to build it. + * + * Carries the piece itself rather than a number pointing into one of the two lists the review + * also reports, because a caller holding a number has to be told which list it counts along and + * can be told wrongly. What is here is what to add; there is nothing to look up. + */ +export type PlannedInput = + | { covenant: ReviewedCovenantInput; source: "covenant" } + | { source: "wallet"; utxo: SelectableUtxo }; + /** One output of the transaction the wallet worked out, ready to be shown and then built. */ export type ReviewedOutput = { + /** + * The asset this output pays in, as the chain writes the id. + * + * Carried rather than assumed, because assuming it is what limited this wallet to one asset: + * a builder told only an amount pays it in whatever asset it defaults to, and a document + * moving a token would have had its token quietly paid out as money. + */ + asset: string; /** * Whether this output hides what it carries, decided by the order the format defines. * @@ -103,11 +142,42 @@ export type ReviewedOutput = { * amount is published when the protocol meant it kept, and nothing later could tell. */ blinded: boolean; + /** + * Whose word that was: the output's own, the document's, the network's, or this wallet's. + * + * The answer and the word behind it are different facts, and only the answer reaches the + * builder. "This protocol asked for it" and "nobody said, and this network hides by + * default" build the identical output and are not the identical sentence, and a person + * deciding whether to trust a site is owed the difference. + */ + decidedBy: BlindingDecision["decidedBy"]; id: string; + /** + * The word this wallet set aside, present only on change it published over the format. + * + * Absent everywhere else, because everywhere else the wallet follows the format and has + * nothing to have overridden. + */ + overrode?: BlindingWord; sats: bigint; scriptPubKeyHex: string; }; +/** + * What one asset this transaction moves does to the wallet's own balance. + * + * One of these per asset rather than one figure for the transaction, because a figure for the + * transaction can only be written by adding assets together, and assets do not add. A person + * approving a swap is agreeing to two different sentences at once, and both have to be on the + * screen for either to be true. + */ +export type AssetMovement = { + /** The asset, by the id the chain knows it as. */ + asset: string; + /** Base units this wallet's balance changes by, negative when it is paying out. */ + sats: bigint; +}; + /** * Everything the wallet established, worked out and decided — before anyone approves it. * @@ -122,10 +192,23 @@ export type ManifestReview = { * Whether the change this transaction returns hides what it carries. * * Change is an output like any other in the document's eyes, and the corpus declares one - * for almost every action while saying nothing about it — so the network's own default - * decides, and on Liquid that means hidden. + * for almost every action while saying nothing about it — so the format's own answer is the + * network's default, and on Liquid that means hidden. This wallet publishes it instead, so + * the money returns in a form the next action can be funded from. + * + * Still derived rather than written as `false`, because the guard checks what was built + * against what was decided, and a guard handed a constant checks nothing. */ changeBlinded: boolean; + /** + * Whose word this wallet set aside to publish that change. + * + * Present whenever the format would have hidden it, which is every action in the published + * corpus — including the ones declaring no change output at all, whose change the signing + * module appends and whose silence the network answers the same way. Absent only where a + * protocol asked for open change itself, because then nothing was overridden. + */ + changeOverrode?: BlindingWord; covenants: CovenantFinding[]; /** The covenant outputs this action spends, ready to be added as inputs. */ covenantInputs: ReviewedCovenantInput[]; @@ -139,6 +222,21 @@ export type ManifestReview = { estimatedFeeSats: bigint; /** What the wallet will pay per kilo-vbyte, established from the chain rather than from the request. */ feeRateSatsPerKvb: number; + /** + * The block height this transaction declares it may not be mined before. + * + * Present whenever the action spends a covenant and the wallet could read the chain's + * height. A contract branch guarded by `check_lock_height` reads the transaction's own + * locktime, so a transaction declaring none satisfies no such branch — and no document in + * the corpus states a locktime, because the height a spend is valid at is a fact about the + * chain rather than about the protocol. The wallet answers with where the chain is, which + * is what every wallet writes there and says nothing about any protocol. + * + * Absent when the action spends no covenant, or when no chain-tip reader was supplied. A + * time-locked branch is then unsatisfiable, and it fails at execution rather than here, + * because whether a branch checks a height is inside the contract rather than the document. + */ + locktimeHeight?: number; /** * Constructs the manifest carries that this runtime did not act on and did not need to. * @@ -155,6 +253,20 @@ export type ManifestReview = { * the transaction actually spends or to one considered and dropped. */ issuances: PlannedIssuance[]; + /** + * The deployment this action brings into existence, when it creates one. + * + * Absent for every action that only spends what already exists. Present, it is the record of + * a contract that has no history yet: the wallet worked out each field, including the ones + * that could not exist until this transaction did, and every covenant this action creates was + * compiled from exactly these values. + * + * Reported rather than kept because the deployment outlives the transaction and nothing else + * can reconstruct it. Half of these fields are functions of outputs the wallet chose — an + * asset id is derived from the output its issuing input spends — so a caller that had to work + * them out again afterwards would be guessing which output the wallet picked. + */ + createdInstance?: CreatedInstance; /** * Everything the person is shown, with every value's origin attached. * @@ -163,10 +275,28 @@ export type ManifestReview = { * site's word, and guessing is the failure the provenance exists to prevent. */ confirmation: ConfirmationModel; + /** + * What this action does to the wallet's balance, one asset at a time. + * + * Worked out here because here is where what the covenants hold, what the outputs cost and + * what the wallet put in are all known at once. A surface handed the outputs alone would + * have to add up assets to reach a single number, which is the sum this bundle removed. + */ + movements: AssetMovement[]; /** Legacy spellings the document used, so the generation it came from can be reported. */ normalisation: NormalisationNote[]; outputs: ReviewedOutput[]; protocol: string; + /** + * The transaction's inputs, in the order they must be added. + * + * A covenant introspects positions, so the order is part of what the document says rather + * than the wallet's to choose. Adding every covenant first and the wallet's own after is one + * order among many, and where a document states a position for an input the wallet supplies + * it is saying that that one is wrong — a contract asserting its own index will not run + * against a transaction built the other way, and nothing after signing could say why. + */ + inputOrder: PlannedInput[]; /** What each input must carry beyond its source, when the action says so. */ inputRules: InputRule[]; /** The wallet's own outputs that fund this, chosen by the wallet. */ @@ -202,9 +332,37 @@ export async function reviewManifestAction( request: ParsedLiquidProcessCtParams, input: { compile: CompileCovenant; - /** The wallet's spendable outputs, and where its own change and payments go. */ + /** + * What a contract says the types of its own compile parameters are. + * + * Injected beside the compile step because it comes from the same place — the compiler — + * and a wallet owns that. It is what types the parameters a deployment writes as a bare + * value rather than as a name: those carry no declared type at the position they are + * written, and a width guessed from the value is part of an address. + * + * A wallet that supplies none still reviews every covenant whose parameters are all + * names, and refuses the ones that are not rather than encoding them on a guess. + */ + contractParamTypes?: ContractParamTypesOf; + /** The wallet's spendable outputs in the asset the network charges its fees in. */ fundingUtxos: SelectableUtxo[]; + /** + * The wallet's spendable outputs in one asset it is not the network's own, by id. + * + * A function rather than a list because which assets an action moves is not knowable + * until its document has been read and its lookups resolved — which happens here. A + * wallet that supplies none can fund an action in the network's asset and nothing else, + * and is told which asset it was short of rather than left to guess. + */ + holdingsOf?: AssetHoldings; network: string; + /** + * How high the chain is, asked only when the action spends a covenant. + * + * Optional because most of what this runtime reviews needs no locktime at all, and a + * wallet that supplies none still builds every action whose covenants are not time-locked. + */ + readChainTip?: ReadChainTip; readFeeRate: ReadFeeRate; readTxOut: ReadTxOut; /** The SimplicityHL version compiled into this wallet, which is the only one it has. */ @@ -222,6 +380,16 @@ export async function reviewManifestAction( const manifest = normalised.manifest; const deployment = normaliseInstance(request.instance); const notes: NormalisationNote[] = [...normalised.notes, ...deployment.notes]; + /** + * The deployment as the wallet can read it, which is more than the site can hold. + * + * Half a deployment's fields are covenant script hashes — compiler output — so a site that + * did not create the deployment carries the ordinary values and nothing else. The document + * says how the rest are computed, in the constructor's own block, and this runtime already + * computes them there. Filled in here rather than demanded of the request, because demanding + * them asks a site for something only a wallet can make. + */ + let deploymentFields: Record<string, unknown> = deployment.instance.fields; // Everything the wallet will not build, before it builds anything. A refusal here is a // refusal: nothing downstream turns one into a prompt. @@ -245,11 +413,32 @@ export async function reviewManifestAction( }; } + /** One compiler, one build mode, for every hash this document works out for itself. */ + const hashCovenant = covenantHashFrom(input.scriptPubKeyOf, buildMode(manifest)); + + // The deployment is completed before anything reads it. A parameter the document computes + // can name any field of the deployment, including one only a compiler can produce and one + // the constructor worked out — `CURRENT_DEBT` is both — so filling parameters against the + // half a site can hold refuses on a field the wallet was about to derive. + if (Object.keys(deploymentFields).length > 0) { + const completed = completeSuppliedInstance(manifest, action, deploymentFields, { + contractSources: request.contractSources, + hashCovenant, + notes, + }); + + if (!completed.ok) { + return { reason: completed.reason, refused: true, reject: "document-fault" }; + } + + deploymentFields = completed.fields; + } + // What the protocol already knows the answer to is filled before anything asks whether the // request is complete. The other order reports a parameter as missing that the document // itself supplies, which sends a site looking for a value it was never meant to send. const filled = fillParameters(action, request.params, { - instance: normaliseInstance(request.instance).instance.fields, + instance: deploymentFields, params: request.params, }); @@ -271,57 +460,74 @@ export async function reviewManifestAction( }; } - const declaredTypes = declaredParamTypes(action.node); + const declaredTypes = declaredParamTypes(manifest, action); const covenants: CovenantFinding[] = []; const covenantInputs: ReviewedCovenantInput[] = []; /** What each covenant input actually holds, read from the chain rather than told. */ const inputs: Record<string, Record<string, unknown>> = {}; /** Which output each input spends, for the ones whose identity an issuance depends on. */ const spent = new Map<string, { txid: string; vout: number }>(); + /** + * What each covenant this action spends actually holds, as the chain reports it. + * + * Kept here rather than read back out of `inputs` because an issuing input's own asset is + * written into that record afterwards — so by the time funding is worked out, the entry for + * a covenant that issues something says the issued asset rather than the one it holds. + */ + const chainHeld: HeldValue[] = []; // The parameters a manifest works out for itself come first: a covenant compiled with // another covenant's hash needs that hash before its own address can be derived, and a // hash cannot depend on what the chain reports at an address that does not exist yet. const computed = resolveComputedParams(action, { contractSources: request.contractSources, - hashCovenant: covenantHashFrom(input.scriptPubKeyOf), + hashCovenant, notes, - scope: { instance: deployment.instance.fields, params: filled.params }, + scope: { instance: deploymentFields, params: filled.params }, }); if (!computed.ok) { return { reason: computed.reason, refused: true, reject: "document-fault" }; } - // A constructor has no deployment to read and creates one instead, so its field values are - // worked out here rather than arriving with the request. They join the scope under the same - // name every other reference reads, because the covenant this action locks funds into is - // compiled with the deployment it is creating. - const created = createsInstance(action) - ? resolveCreatedInstance(action, { - contractSources: request.contractSources, - hashCovenant: covenantHashFrom(input.scriptPubKeyOf), - notes, - scope: { - instance: deployment.instance.fields, - params: { ...filled.params, ...computed.values }, - }, - }) - : undefined; + /** The deployment this action creates, read against whatever is settled at the time. */ + const createdFields = (unresolved: "omit" | "refuse", reading: ReferenceScope) => + createsInstance(action) + ? resolveCreatedInstance(action, { + contractSources: request.contractSources, + hashCovenant, + notes, + scope: reading, + unresolved, + }) + : undefined; + + // A constructor has no deployment to read and creates one instead. Only the half of it the + // request and the existing deployment already determine is known here — the rest is a + // function of outputs nothing has chosen yet — and that half is needed now, because which + // asset an issuing input carries is itself one of these fields. + const known = createdFields("omit", { + inputs, + instance: deploymentFields, + params: { ...filled.params, ...computed.values }, + }); - if (created && !created.ok) { - return { reason: created.reason, refused: true, reject: "document-fault" }; + if (known && !known.ok) { + return { reason: known.reason, refused: true, reject: "document-fault" }; } let scope: ReferenceScope = { inputs, - instance: created - ? { ...deployment.instance.fields, ...created.instance.fields } - : deployment.instance.fields, + instance: known ? { ...deploymentFields, ...known.instance.fields } : deploymentFields, params: { ...filled.params, ...computed.values }, }; - for (const site of covenantSites(action)) { + // The covenants this action spends, which are the ones there is something on chain to + // compare against. The ones it creates are derived further down, after the inputs have + // produced what only they can — and no refusal changes place by that, because a spent + // covenant is named by an input and a created one by an output, so the declared order + // already puts every spent site first. + for (const site of covenantSites(action).filter((declared) => declared.role === "spent")) { // Sequential on purpose, and the rule is disabled here rather than obeyed. This loop // returns on the first site it refuses, so running the sites concurrently would compile // contracts and send chain reads for covenants after the answer is already known, and @@ -331,6 +537,7 @@ export async function reviewManifestAction( // oxlint-disable-next-line no-await-in-loop const derived = await deriveCovenantAddress(manifest, { compile: input.compile, + contractParamTypes: input.contractParamTypes, contractSources: request.contractSources, declaredTypes, includeDebugSymbols: buildMode(manifest), @@ -345,18 +552,6 @@ export async function reviewManifestAction( return { reason: derived.reason, refused: true, reject: "document-fault" }; } - if (site.role === "created") { - covenants.push({ - address: derived.derivation.address, - role: "created", - scriptPubKeyHex: derived.derivation.scriptPubKeyHex, - utxoType: site.utxoType, - verified: "not-yet-on-chain", - }); - - continue; - } - const outpoint = findStateOutpoint(request, site.utxoType); if (!outpoint) { @@ -413,9 +608,19 @@ export async function reviewManifestAction( const { txOutHex } = onChain; + if (site.id) { + chainHeld.push({ + asset: onChain.rawAssetId, + id: site.id, + sats: BigInt(onChain.amountSats), + }); + } + covenantInputs.push({ argumentsJson: derived.derivation.argumentsJson, + extraLeavesJson: derived.derivation.extraLeavesJson, id: site.id, + includeDebugSymbols: buildMode(manifest), ...(site.signatureWitness === undefined ? {} : { signatureWitness: site.signatureWitness }), source: derived.derivation.source, txOutHex, @@ -432,14 +637,45 @@ export async function reviewManifestAction( }); } + /** + * The wallet's own outputs in one asset, whichever asset an action turns out to move. + * + * The network's own asset comes from the list the caller always supplies; every other asset + * is asked for by id, because which ones there are cannot be known before the document has + * been read. A caller that supplies no reader holds nothing in any other asset, which is a + * shortfall named by asset rather than a silent refusal. + */ + const pools = new Map<string, SelectableUtxo[]>(); + const holdings: AssetHoldings = (asset) => { + const existing = pools.get(asset); + + if (existing) { + return existing; + } + + // Asked for once per asset and kept. A wallet answering this from its own snapshot builds + // the list fresh each time it is asked, so asking twice yields two lists of equal outputs + // that share no identity — and the output already committed to for an issuance would be + // offered again as if it were a different one. + const pool = + asset === input.policyAsset.trim().toLowerCase() + ? input.fundingUtxos + : (input.holdingsOf?.(asset) ?? []); + + pools.set(asset, pool); + + return pool; + }; + // An asset an action creates is derived from the output its issuing input spends, so that // output is settled here rather than at the selection below: an input's own hook reads the // asset as soon as the input resolves, and an id derived from an output the wallet had not // yet committed to spending would be an id for a different asset. const issued = resolveIssuances(action, { - fundingUtxos: input.fundingUtxos, + holdings, inputs, notes, + policyAsset: input.policyAsset, scope, spent, }); @@ -460,6 +696,53 @@ export async function reviewManifestAction( scope = hooked.scope; + // The deployment being created, now that everything it can depend on exists. Read a second + // time rather than patched: a field whose value came out of an issuance was not merely + // missing before, and the covenant hashes among these fields are worked out from all of + // them at once. What is unresolved here is unresolved for good, and refuses by name. + const created = createdFields("refuse", scope); + + if (created && !created.ok) { + return { reason: created.reason, refused: true, reject: "document-fault" }; + } + + if (created) { + scope = { ...scope, instance: { ...scope.instance, ...created.instance.fields } }; + } + + // The covenants this action creates, derived once the deployment they are compiled with is + // complete. There is nothing on chain to compare them against — that is the point of + // creating one — so the wallet reports what it derived and that it derived it, which is a + // different fact from a check that passed rather than a weaker one. + for (const site of covenantSites(action).filter((declared) => declared.role === "created")) { + // Sequential for the same reason the loop above is: the first refusal is the answer. + // oxlint-disable-next-line no-await-in-loop + const derived = await deriveCovenantAddress(manifest, { + compile: input.compile, + contractParamTypes: input.contractParamTypes, + contractSources: request.contractSources, + declaredTypes, + includeDebugSymbols: buildMode(manifest), + network: input.network, + notes, + scope, + utxoType: site.utxoType, + wiring: site.wiring, + }); + + if (!derived.ok) { + return { reason: derived.reason, refused: true, reject: "document-fault" }; + } + + covenants.push({ + address: derived.derivation.address, + role: "created", + scriptPubKeyHex: derived.derivation.scriptPubKeyHex, + utxoType: site.utxoType, + verified: "not-yet-on-chain", + }); + } + // Stated witness values come after the hooks, because one published protocol selects its // branch by a field of its own deployment and a hook is what may have written it. const stated = resolveStaticWitnesses(action, scope, notes); @@ -533,6 +816,40 @@ export async function reviewManifestAction( return { reason: plan.reason, refused: true, reject: "document-fault" }; } + // Which asset each piece of value is in, decided here because here is the first place it is + // knowable. The document states an asset as a lookup far more often than as an id, and a + // lookup resolves against this deployment's fields and the request — both of which have been + // read by now and neither of which the document carries. + // + // What replaced one running total is one per asset. Netting what the covenants hold against + // what the outputs cost is sound within an asset and meaningless across two: added together, + // a one-of-a-kind token and a thousand base units of money make a number that describes + // nothing, and a wallet cannot say which of them it is short of. So each asset is reckoned, + // funded and returned on its own from here on. + const reckoned = assetLedger(action, plan.plan.outputs, { + held: [ + ...chainHeld, + // An issuance creates its units out of nothing, so the transaction brings them rather + // than the wallet finding them. Left out, the wallet would go looking for an asset + // that does not exist yet and refuse the action for not holding any of it. + ...issued.issuances.map((issuance) => ({ + asset: issuance.asset, + created: true as const, + id: issuance.inputId, + sats: issuance.assetAmountSats, + })), + ], + notes, + policyAsset: input.policyAsset, + scope: { ...scope, fee: estimatedFee }, + }); + + if (!reckoned.ok) { + return { reason: reckoned.reason, refused: true, reject: reckoned.reject }; + } + + const ledger = reckoned.ledger; + // What the action requires of each input beyond where the money comes from: a relative // timelock a covenant may depend on, and an address it may pin funding to. const inputRules = resolveInputRules(action, { ...scope, fee: estimatedFee }, notes); @@ -570,17 +887,125 @@ export async function reviewManifestAction( }; } - /** Whether the transaction's change hides what it carries, by the same order. */ - const changeBlinded = - plan.plan.outputs.find((planned) => planned.target.kind === "change")?.blinding.blinding === - "hidden"; + const policyAsset = input.policyAsset.trim().toLowerCase(); + /** + * The change outputs the signing module appends for itself. + * + * Only the network's own asset gets one: the fee is charged in it, and what the fee leaves + * behind is not known until the signed transaction has been weighed. Every other asset's + * change is an exact figure this wallet works out and builds in the position the document + * declares it, because nothing takes a bite out of it. + */ + const networkChange = plan.plan.outputs.filter( + (planned, at) => planned.target.kind === "change" && ledger.outputs[at] === policyAsset, + ); + /** Whether the transaction's own change hides what it carries, by the same order. */ + const changeBlinded = networkChange[0]?.blinding.blinding === "hidden"; + /** + * Whose word was set aside to publish it. + * + * An action declaring no change output still gets one — the module appends it — and the + * document's silence about an output it never declared is answered by this network exactly + * as its silence about one it did. So the two say the same thing to a person rather than + * one of them saying nothing, which is what they did before. + */ + const changeOverrode: BlindingWord | undefined = + networkChange.length === 0 ? "chain" : networkChange[0]?.blinding.overrode; + + // An action pinning an input to one address restricts what the wallet may fund it from. + // A protocol requiring a specific address is usually requiring a specific key, and funding + // it from whatever the wallet happens to hold builds a transaction it did not ask for. + const pinned = inputRules.rules.find((rule) => rule.fromAddress !== undefined)?.fromAddress; + const pinnedHoldings: AssetHoldings = (asset) => + pinned === undefined + ? holdings(asset) + : holdings(asset).filter((utxo) => utxo.scriptPubKeyHex === pinned); + + if ( + pinned !== undefined && + pinnedHoldings(policyAsset).length === 0 && + issued.reserved.length === 0 + ) { + return { + reason: `This action must be funded from ${pinned}, and this wallet holds nothing there.`, + refused: true, + reject: "no-funds-at-signing-address", + }; + } + + // An output committed to for an issuance was chosen before the action's address pin could + // be resolved, because the asset id depends on it and the hooks that read that id run + // first. Where the two disagree the action is refused rather than built from the other + // output: moving the issuance would mint a different asset than the one already computed. + const misplaced = + pinned === undefined + ? undefined + : issued.reserved.find(({ utxo }) => utxo.scriptPubKeyHex !== pinned); + + if (misplaced) { + return { + reason: + `This action must be funded from ${pinned}, and the output it issues an asset from ` + + "is not there.", + refused: true, + reject: "no-funds-at-signing-address", + }; + } + + // Each asset funded out of what the wallet holds in that asset, and short in one of them is + // a refusal that says which one. The fee is added to the network's own asset and to no + // other: a second asset never becomes a second fee. + const funding = fundAssets(ledger.entries, { + feeSats: estimatedFee, + headroomSats: BigInt(Math.ceil(feeRateSatsPerKvb)), + holdings: pinnedHoldings, + policyAsset: input.policyAsset, + reserved: issued.reserved, + }); + + if (!funding.ok) { + return { reason: funding.reason, refused: true, reject: funding.reject }; + } + + const fundedFor = new Map(funding.funded.map((entry) => [entry.asset, entry])); const covenantScripts = new Map( covenants.map((found) => [found.utxoType, found.scriptPubKeyHex]), ); const outputs: ReviewedOutput[] = []; + /** Where each declared output lands, which is not its declared position once one is dropped. */ + const outputAt = new Map<string, number>(); + /** What comes back to this wallet from the action's own outputs, per asset. */ + const returned = new Map<string, bigint>(); + + for (const [at, planned] of plan.plan.outputs.entries()) { + const asset = ledger.outputs[at] ?? policyAsset; + + // Change in the network's own asset is the module's to work out and to append. Change in + // any other asset is this wallet's, built here in the position the document declares it, + // for exactly what is left over — and skipped when nothing is, because an output paying + // nothing is not an output. + if (planned.target.kind === "change") { + const surplus = asset === policyAsset ? 0n : (fundedFor.get(asset)?.changeSats ?? 0n); + + if (surplus <= 0n) { + continue; + } + + outputAt.set(planned.id, outputs.length); + outputs.push({ + asset, + blinded: planned.blinding.blinding === "hidden", + decidedBy: planned.blinding.decidedBy, + id: planned.id, + ...(planned.blinding.overrode === undefined ? {} : { overrode: planned.blinding.overrode }), + sats: surplus, + scriptPubKeyHex: input.walletScriptPubKeyHex, + }); + + continue; + } - for (const planned of plan.plan.outputs) { - if (planned.target.kind === "change" || planned.sats === undefined) { + if (planned.sats === undefined) { continue; } @@ -603,70 +1028,79 @@ export async function reviewManifestAction( }; } + if (planned.target.kind === "wallet") { + returned.set(asset, (returned.get(asset) ?? 0n) + planned.sats); + } + + outputAt.set(planned.id, outputs.length); outputs.push({ + asset, blinded: planned.blinding.blinding === "hidden", + decidedBy: planned.blinding.decidedBy, id: planned.id, sats: planned.sats, scriptPubKeyHex, }); } - // An action pinning an input to one address restricts what the wallet may fund it from. - // A protocol requiring a specific address is usually requiring a specific key, and funding - // it from whatever the wallet happens to hold builds a transaction it did not ask for. - const pinned = inputRules.rules.find((rule) => rule.fromAddress !== undefined)?.fromAddress; - const fundable = ( - pinned === undefined - ? input.fundingUtxos - : input.fundingUtxos.filter((utxo) => utxo.scriptPubKeyHex === pinned) - ).filter((utxo) => !issued.reserved.includes(utxo)); - - if (pinned !== undefined && fundable.length === 0 && issued.reserved.length === 0) { - return { - reason: `This action must be funded from ${pinned}, and this wallet holds nothing there.`, - refused: true, - reject: "no-funds-at-signing-address", - }; - } - - // An output committed to for an issuance was chosen before the action's address pin could - // be resolved, because the asset id depends on it and the hooks that read that id run - // first. Where the two disagree the action is refused rather than built from the other - // output: moving the issuance would mint a different asset than the one already computed. - const misplaced = - pinned === undefined - ? undefined - : issued.reserved.find((utxo) => utxo.scriptPubKeyHex !== pinned); - - if (misplaced) { - return { - reason: - `This action must be funded from ${pinned}, and the output it issues an asset from ` + - "is not there.", - refused: true, - reject: "no-funds-at-signing-address", - }; - } - - // What the outputs committed to for an issuance already bring, which the selection below - // does not have to find again. - const held = issued.reserved.reduce((total, utxo) => total + toSats(utxo.amount), 0n); - const outstanding = plan.plan.fundingSats - held; - const selection: CoinSelection = - outstanding > 0n - ? selectCoins(fundable, outstanding, BigInt(Math.ceil(feeRateSatsPerKvb))) - : { ok: true, selected: [], totalSats: held }; + // What each asset does to this wallet's balance: what the transaction brings in it, less + // what the action pays out of it, plus what comes back — and the fee, in the one asset the + // network charges it in. Change is not added: it is already the difference between the two. + const movements: AssetMovement[] = ledger.entries.map((entry) => ({ + asset: entry.asset, + sats: + entry.held - + entry.needed + + (returned.get(entry.asset) ?? 0n) - + (entry.asset === policyAsset ? estimatedFee : 0n), + })); + + // The wallet's own outputs, one asset's worth at a time, in the order the action declares the + // inputs that need them. A pool rather than a running order: which of them lands where is + // decided below, against what the document states, because a covenant reads positions and + // appending them after the covenants is the wallet's habit rather than the document's word. + const fundedOrder = [ + ...new Set([ + ...ledger.walletInputs.map((wallet) => wallet.asset), + ...funding.funded.map((entry) => entry.asset), + ]), + ]; + const selected = fundedOrder.flatMap((asset) => fundedFor.get(asset)?.selected ?? []); + /** Which of the chosen outputs build each declared input, in the order they were chosen. */ + const walletRuns = new Map<string, PlannedInput[]>(); + /** A declared input built out of the selection an earlier declaration was credited with. */ + const fundedWith = new Map<string, string>(); + /** Which declared input was credited with each asset's whole selection. */ + const creditedWith = new Map<string, string>(); + + for (const wallet of ledger.walletInputs) { + const credited = creditedWith.get(wallet.asset); + + // Two declared inputs in one asset are funded out of one selection, and nothing in the + // document says which of the chosen outputs belongs to which of them. The first one is + // credited with the whole selection; the second is told it lands where that run ends, + // which is the honest answer to a question the document did not answer. + if (credited !== undefined) { + fundedWith.set(wallet.id, credited); + continue; + } - if (!selection.ok) { - return { reason: selection.reason, refused: true, reject: "shortfall" }; + creditedWith.set(wallet.asset, wallet.id); + walletRuns.set( + wallet.id, + (fundedFor.get(wallet.asset)?.selected ?? []).map((utxo) => ({ + source: "wallet" as const, + utxo, + })), + ); } - // Where each piece actually lands, against where the document says it must. The wallet - // builds covenant inputs in the order the action declares them and then its own outputs, - // and it builds the declared outputs in order with its change last — so the layout is - // known here, and a piece that cannot land where it was asked to is refused by name. - const positions: StatedPosition[] = []; - let walletInputRank = 0; + /** Outputs chosen for an asset no input declares, which is where an undeclared fee is paid. */ + const undeclared = fundedOrder + .filter((asset) => !creditedWith.has(asset)) + .flatMap((asset) => fundedFor.get(asset)?.selected ?? []); + /** Every declared input, with what the wallet would build it from and where it must go. */ + const placeable: PlaceableInput<PlannedInput>[] = []; for (const entry of asArray(action.node.inputs)) { const declared = asRecord(entry); @@ -676,74 +1110,138 @@ export async function reviewManifestAction( } const id = typeof declared.id === "string" ? declared.id : "(unnamed)"; - const covenantAt = covenantInputs.findIndex((covenant) => covenant.id === id); - const at = covenantAt >= 0 ? covenantAt : covenantInputs.length + walletInputRank; + const covenant = covenantInputs.find((candidate) => candidate.id === id); + const slots: PlannedInput[] = covenant + ? [{ covenant, source: "covenant" }] + : (walletRuns.get(id) ?? []); + + placeable.push({ + id, + slots, + ...(typeof declared.required_index === "number" ? { stated: declared.required_index } : {}), + }); + } + + if (undeclared.length > 0) { + placeable.push({ slots: undeclared.map((utxo) => ({ source: "wallet", utxo })) }); + } - if (covenantAt < 0) { - walletInputRank += 1; + const placement = placeInputs(placeable); + /** + * Where a declared input landed, which is not always anywhere. + * + * One funded out of an earlier declaration's selection lands where that run ends, and one the + * wallet builds nothing for lands nowhere at all rather than at a number that reads like a + * place. + */ + const landedAt = (id: string): number | undefined => { + const own = placement.at.get(id); + + if (own !== undefined) { + return own; } - if (typeof declared.required_index === "number") { - positions.push({ at, id, kind: "input", stated: declared.required_index }); + const credited = fundedWith.get(id); + + if (credited === undefined) { + return undefined; } - } - for (const [at, planned] of outputs.entries()) { - const declared = asArray(action.node.outputs) - .map((entry) => asRecord(entry)) - .find((entry) => entry?.id === planned.id); + const start = placement.at.get(credited); + + return start === undefined ? undefined : start + (walletRuns.get(credited)?.length ?? 0); + }; + + // Where each piece actually lands, against where the document says it must. The inputs were + // laid out above in the order the document states, and the declared outputs are built in + // order with the network's change last — so the layout is known here, and a piece that could + // not land where it was asked to is refused by name. + const positions: StatedPosition[] = []; + + for (const entry of asArray(action.node.inputs)) { + const declared = asRecord(entry); - if (typeof declared?.required_index === "number") { - positions.push({ at, id: planned.id, kind: "output", stated: declared.required_index }); + if (!declared || typeof declared.required_index !== "number") { + continue; } - } - // Change is the wallet's own and the builder appends it last, so its position is known - // without being chosen. A document stating one for it is stating one the wallet can only - // meet by accident. - const changeOutputs = plan.plan.outputs.filter((planned) => planned.target.kind === "change"); + const id = typeof declared.id === "string" ? declared.id : "(unnamed)"; - for (const planned of changeOutputs) { - const declared = asArray(action.node.outputs) - .map((entry) => asRecord(entry)) - .find((entry) => entry?.id === planned.id); + positions.push({ + // An input the wallet builds nothing for is counted one past the end, which no stated + // position can equal: it has no place, and being refused by name is what says so. + at: landedAt(id) ?? placement.order.length, + id, + kind: "input", + stated: declared.required_index, + }); + } - if (typeof declared?.required_index === "number") { - positions.push({ - at: outputs.length, - id: planned.id, - kind: "output", - stated: declared.required_index, - }); + for (const entry of asArray(action.node.outputs)) { + const declared = asRecord(entry); + const id = typeof declared?.id === "string" ? declared.id : undefined; + + if (!declared || id === undefined || typeof declared.required_index !== "number") { + continue; } + + // The network's change is appended by the module after everything else, so a document + // stating a position for it is stating one the wallet could only meet by accident. An + // output the wallet dropped for paying nothing is counted where it would have been. + positions.push({ + at: outputAt.get(id) ?? outputs.length, + id, + kind: "output", + stated: declared.required_index, + }); } const positioned = checkPositions(positions, { - inputs: covenantInputs.length + selection.selected.length, - outputs: outputs.length + Math.min(changeOutputs.length, 1), + inputs: placement.order.length, + outputs: outputs.length + Math.min(networkChange.length, 1), }); if (!positioned.ok) { return { reason: positioned.reason, refused: true, reject: "unbuildable-position" }; } + /** + * Where the chain is, when this action spends a covenant. + * + * Asked here rather than at the top because most actions never need it, and a failure to + * read it is not a reason to refuse an action whose covenants are not time-locked: the + * branch that needs a height fails at execution, naming itself, which is a better answer + * than refusing everything because one network call did not come back. + */ + const locktimeHeight = + covenantInputs.length > 0 && input.readChainTip + ? await input.readChainTip().catch(() => undefined) + : undefined; + const review: ManifestReview = { action: request.action, confirmation: {} as ConfirmationModel, covenantInputs, covenants, + ...(created === undefined ? {} : { createdInstance: created.instance }), estimatedFeeSats: estimatedFee, feeRateSatsPerKvb, changeBlinded, + ...(changeOverrode === undefined ? {} : { changeOverrode }), ignoredConstructs: ignored(inspectConstructs(manifest)), issuances: issued.issuances, + ...(locktimeHeight === undefined ? {} : { locktimeHeight }), + movements, normalisation: notes, outputs, + inputOrder: placement.order, inputRules: inputRules.rules, protocol: manifest.protocol ?? "", - // The outputs an issuance is derived from come first and in the order the action - // declares them, because each asset id is a statement about one of them. - selected: [...issued.reserved, ...selection.selected], + // Each asset's own outputs together, in the order the action declares the inputs that + // need them, with the output an issuance is derived from first within its asset — each + // asset id is a statement about one of them. Which of them the transaction spends, not + // in which order: `inputOrder` is the order, and it is not always this one. + selected, }; return { @@ -759,7 +1257,7 @@ export async function reviewManifestAction( const FEE_TARGET_BLOCKS = 6; type ResolvedIssuances = - | { issuances: PlannedIssuance[]; ok: true; reserved: SelectableUtxo[] } + | { issuances: PlannedIssuance[]; ok: true; reserved: { asset: string; utxo: SelectableUtxo }[] } | { ok: false; reason: string; reject: RejectToken }; /** @@ -778,26 +1276,59 @@ type ResolvedIssuances = function resolveIssuances( action: NormalisedAction, context: { - fundingUtxos: SelectableUtxo[]; + /** The wallet's spendable outputs in one asset, which is where an issuing input comes from. */ + holdings: AssetHoldings; /** What the wallet established about each input, which the issued asset joins. */ inputs: Record<string, Record<string, unknown>>; notes: NormalisationNote[]; + policyAsset: string; scope: ReferenceScope; spent: Map<string, { txid: string; vout: number }>; }, ): ResolvedIssuances { const issuances: PlannedIssuance[] = []; - const reserved: SelectableUtxo[] = []; - - // Smallest first, and it is a choice about what is left rather than about this input: an - // issuance needs an output's identity and not its value, so taking the smallest leaves the - // most behind to fund the action with. Nothing here honours an amount the input declares - // for itself — no wallet input's amount is honoured today — which is recorded rather than - // hidden, because a protocol whose issuing input is also its collateral gets an output - // chosen for the wrong reason. - const spare = context.fundingUtxos - .filter((utxo) => utxo.spendable && !utxo.confidential) - .toSorted((one, other) => (toSats(one.amount) > toSats(other.amount) ? 1 : -1)); + const reserved: { asset: string; utxo: SelectableUtxo }[] = []; + const policyAsset = context.policyAsset.trim().toLowerCase(); + const pools = new Map<string, SelectableUtxo[]>(); + + /** + * The wallet's own outputs an issuing input may be derived from, in the order to take them. + * + * Per asset, because an issuing input is an input like any other: it carries the asset the + * action says it carries, and deriving an asset id from an output in a different one commits + * this transaction to spending an output that has no business in it. + * + * Smallest first in the asset the network charges its fees in — an issuance needs an output's + * identity rather than its value, so taking the smallest leaves the most behind to pay with. + * Largest first in any other asset, where the same input is usually also the one carrying + * that asset's amount, and where moving the issuance to a second output would mint a + * different asset. Neither honours an amount the input declares for itself; no wallet input's + * amount is honoured today, and that is recorded rather than hidden. + */ + const spareIn = (asset: string): SelectableUtxo[] => { + const existing = pools.get(asset); + + if (existing) { + return existing; + } + + const ordered = context + .holdings(asset) + .filter((utxo) => utxo.spendable && !utxo.confidential) + .toSorted((one, other) => + asset === policyAsset + ? toSats(one.amount) > toSats(other.amount) + ? 1 + : -1 + : toSats(other.amount) > toSats(one.amount) + ? 1 + : -1, + ); + + pools.set(asset, ordered); + + return ordered; + }; for (const entry of asArray(action.node.inputs)) { const declared = asRecord(entry); @@ -809,15 +1340,25 @@ function resolveIssuances( const id = typeof declared.id === "string" ? declared.id : "(unnamed)"; const onChain = context.spent.get(id); - const funding = onChain ? undefined : spare[reserved.length]; + const asset = resolveAsset(declared.asset, `input ${id}`, { + notes: context.notes, + policyAsset: context.policyAsset, + scope: context.scope, + }); + + if (!asset.ok) { + return { ok: false, reason: asset.reason, reject: "foreign-asset" }; + } + + const funding = onChain ? undefined : spareIn(asset.id).shift(); const outpoint = onChain ?? (funding && { txid: funding.txid, vout: funding.vout }); if (!outpoint) { return { ok: false, reason: - `Input ${id} issues an asset, which needs one of this wallet's own outputs to ` + - "derive it from, and there is none left to use.", + `Input ${id} issues an asset, which needs one of this wallet's own outputs in ` + + `${asset.id} to derive it from, and there is none left to use.`, reject: "shortfall", }; } @@ -833,7 +1374,7 @@ function resolveIssuances( } if (funding) { - reserved.push(funding); + reserved.push({ asset: asset.id, utxo: funding }); } issuances.push(resolved.issuance); @@ -865,20 +1406,6 @@ function findStateOutpoint( return undefined; } -function declaredParamTypes(action: Record<string, unknown>): Record<string, string> { - const types: Record<string, string> = {}; - - for (const [name, declared] of Object.entries(asRecord(action.params) ?? {})) { - const type = asRecord(declared)?.type; - - if (typeof type === "string") { - types[name] = type; - } - } - - return types; -} - /** * Runs every hook this action declares, in the order the format defines. * diff --git a/packages/tx-manifest/src/review/multiAsset.test.ts b/packages/tx-manifest/src/review/multiAsset.test.ts new file mode 100644 index 0000000..30e64d0 --- /dev/null +++ b/packages/tx-manifest/src/review/multiAsset.test.ts @@ -0,0 +1,553 @@ +import { describe, expect, test } from "bun:test"; + +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import type { TxOutAtOutPoint } from "../chain/chainRead"; +import { txOutAt } from "../chain/txOut"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import type { SelectableUtxo } from "./coinSelection"; +import { isRefusal, reviewManifestAction } from "./index"; + +/** + * The corpus's simplest spend, moving a second asset the way the corpus's own swaps do. + * + * `dex.manifest.json` and `lending_v3.manifest.json` both settle in two assets at once, and + * both write it the same way: an input in the second asset stated as a lookup, an output + * paying it, and a change output in that same asset beside the one for the fee. That shape is + * what is added here — to `p2pk`, because it is the one published document this runtime can + * compile end to end today. The real ones are read where they can be read without a compiler, + * in `evaluation/assetLedger.test.ts`, and the arithmetic under both is the same. + * + * An asset is stated as a lookup rather than as an id because that is what every published + * protocol does, and because a document naming an id outright is still refused from the + * document alone. + */ +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const TOKEN = "aa".repeat(32); +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const SOURCE_PATH = "./p2pk.simf"; +const SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; +const COVENANT_TXID = "b".repeat(64); +const COVENANT_SATS = 42_000n; +const TOKEN_PAID = 250_000n; +const DERIVED = "tex1p_derived"; +const DERIVED_SCRIPT = "5120" + "11".repeat(32); +const WALLET_SCRIPT = "0014" + "11".repeat(20); + +/** `Receive`, with a second asset paid through it in the shape the two-asset corpus uses. */ +function swapManifest() { + const document = structuredClone(p2pkManifest) as unknown as { + actions: { + Receive: { + inputs: Record<string, unknown>[]; + outputs: Record<string, unknown>[]; + params: Record<string, unknown>; + }; + }; + }; + const receive = document.actions.Receive; + + receive.params.token = { description: "The asset being paid.", type: "liquid.asset_id" }; + receive.params.paid = { description: "How much of it.", type: "u64" }; + receive.inputs.push({ + asset: "params.token", + description: "The taker's own outputs in the asset being paid.", + id: "payment_in", + utxo_source: "wallet", + }); + receive.outputs.unshift({ + amount_sat: "params.paid", + asset: "params.token", + confidential: false, + description: "The payment, locked where the protocol wants it.", + destination: { compile_params: { PUB_KEY: "params.pubkey" }, utxo_type: "p2pk_output" }, + id: "payment_out", + }); + receive.outputs.push({ + asset: "params.token", + description: "What is left of the payment asset, back to the wallet.", + destination: "change", + id: "payment_change", + optional: true, + }); + + return document as unknown as Record<string, unknown>; +} + +function utxo(amount: string, txid: string): SelectableUtxo { + return { amount, spendable: true, txid: txid.padStart(64, "0"), txOut: "00", vout: 0 }; +} + +/** + * The same output as a different object. + * + * A wallet answers "what do I hold" from its own snapshot and builds the list fresh each time, + * so two answers to the same question describe the same outputs and share no identity. Every + * substitute here does the same, because a substitute that returned the same objects twice + * would make an identity comparison look correct. + */ +function again(one: SelectableUtxo): SelectableUtxo { + return { ...one }; +} + +/** + * A chain read that answers with real bytes for an output in a chosen asset. + * + * The serialised output goes back through the parser the production reader uses, so an asset + * this substitute writes wrongly fails here rather than passing through green. + */ +const readTxOut = (asset: string, amountSats: bigint) => async (): Promise<TxOutAtOutPoint> => { + const committed = `01${(asset.match(/../g) ?? []).toReversed().join("")}`; + const value = `01${amountSats.toString(16).padStart(16, "0")}`; + const script = `${(DERIVED_SCRIPT.length / 2).toString(16).padStart(2, "0")}${DERIVED_SCRIPT}`; + const parsed = txOutAt(`02000000000001${committed}${value}00${script}00000000`, 0); + + if (!parsed.ok) { + throw new Error(`This substitute built an output the parser cannot read: ${parsed.reason}`); + } + + return parsed.txOut; +}; + +const request: ParsedLiquidProcessCtParams = { + action: "Receive", + broadcast: false, + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: swapManifest(), + params: { paid: TOKEN_PAID.toString(), pubkey: PUBKEY, token: TOKEN }, + state: { utxos: [{ txid: COVENANT_TXID, utxo_type: "p2pk_output", vout: 0 }] }, +}; + +function settle( + holdings: Record<string, SelectableUtxo[]>, + overrides: { holdingsOf?: undefined } = {}, +) { + return reviewManifestAction(request, { + accountLabel: "liquid:testnet account 0", + compile: () => ({ address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }), + compilerVersion: "0.6.0", + fundingUtxos: holdings[POLICY_ASSET] ?? [], + holdingsOf: (asset) => holdings[asset] ?? [], + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: readTxOut(POLICY_ASSET, COVENANT_SATS), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + ...overrides, + }); +} + +/** Enough of both, with the payment asset over-supplied so change is required. */ +const FUNDED = { + [POLICY_ASSET]: [utxo("1000000", "c")], + [TOKEN]: [utxo("400000", "d")], +}; + +describe("an action that moves more than one asset", () => { + test("is funded in each asset out of what the wallet holds in that asset", async () => { + const result = await settle(FUNDED); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + // One from each pool, in the order the action declares the inputs that need them. A + // wallet keeping one running total would have taken both out of one pool. + expect(result.selected.map((selected) => selected.amount)).toEqual(["1000000", "400000"]); + }); + + test("returns the change for each asset separately, and for the exact amount left", async () => { + const result = await settle(FUNDED); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + // 400000 put in, 250000 paid. What is left comes back in the asset it went in as, and the + // figure is exact because nothing takes a fee out of it. + expect(result.outputs.find((output) => output.id === "payment_change")).toMatchObject({ + asset: TOKEN, + sats: 150_000n, + scriptPubKeyHex: WALLET_SCRIPT, + }); + // The network's own change is not among them: that one is the signing module's, and its + // amount is not known until the signed transaction has been weighed. + expect(result.outputs.map((output) => output.id)).not.toContain("fee_change"); + }); + + test("pays each output in the asset the document states for it", async () => { + const result = await settle(FUNDED); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + expect(Object.fromEntries(result.outputs.map((output) => [output.id, output.asset]))).toEqual({ + payment_change: TOKEN, + payment_out: TOKEN, + received_out: POLICY_ASSET, + }); + }); + + test("says what it is paying in, one line per asset", async () => { + const result = await settle(FUNDED); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + const effects = Object.fromEntries( + result.confirmation.netEffect.map((effect) => [effect.asset.value, effect.sats.value]), + ); + + // Two sentences and no sum of them: the payment leaves in one asset, and the covenant's + // own balance arrives in the other, less the fee. + expect(effects[TOKEN]).toBe(-TOKEN_PAID); + expect(effects[POLICY_ASSET]).toBe(COVENANT_SATS - result.estimatedFeeSats); + }); + + // The covenant already holds what the action pays out in that asset, so the wallet funds + // none of it. Netting per asset is what makes that visible; one total demanded the wallet + // hold the whole balance again in order to reclaim it. + test("counts what the covenant it spends already holds against what the outputs cost", async () => { + const result = await settle({ ...FUNDED, [POLICY_ASSET]: [utxo("6000", "c")] }); + + // Six thousand covers the fee and nothing like the covenant's own forty-two. + expect(isRefusal(result)).toBe(false); + }); +}); + +describe("when the wallet cannot fund one of them", () => { + test("it refuses before signing and names the asset it is short of", async () => { + const result = await settle({ ...FUNDED, [TOKEN]: [utxo("1000", "d")] }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reject).toBe("shortfall"); + expect(result.reason).toContain(TOKEN); + expect(result.reason).toContain("1000"); + // The fee is charged in one asset, so a shortfall in another is never explained by it. + expect(result.reason).not.toContain("fee"); + } + }); + + test("holding none of it at all is the same refusal, not a different one", async () => { + const result = await settle({ [POLICY_ASSET]: [utxo("1000000", "c")] }); + + expect(isRefusal(result) && result.reason).toContain(TOKEN); + }); + + // A wallet full of money and empty of the token is the case one running total could not + // tell apart from a wallet with no money at all. + test("being short of the network's own asset still says so in its own terms", async () => { + const result = await settle({ ...FUNDED, [POLICY_ASSET]: [] }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reject).toBe("shortfall"); + expect(result.reason).toContain("pay its fee"); + } + }); + + test("a wallet that cannot look up another asset is short of it, not refused for it", async () => { + const result = await settle(FUNDED, { holdingsOf: undefined }); + + expect(isRefusal(result) && result.reject).toBe("shortfall"); + expect(isRefusal(result) && result.reason).toContain(TOKEN); + }); + + // Selection can overshoot, and an asset with no declared change output has nowhere for the + // overshoot to go. Building it anyway would destroy the difference. + test("refuses an asset whose surplus the document declares nowhere to return", async () => { + const document = structuredClone(request.manifest) as unknown as { + actions: { Receive: { outputs: Record<string, unknown>[] } }; + }; + + document.actions.Receive.outputs = document.actions.Receive.outputs.filter( + (output) => output.id !== "payment_change", + ); + + const result = await reviewManifestAction( + { ...request, manifest: document as unknown as Record<string, unknown> }, + { + accountLabel: "liquid:testnet account 0", + compile: () => ({ address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }), + compilerVersion: "0.6.0", + fundingUtxos: FUNDED[POLICY_ASSET] ?? [], + holdingsOf: (asset) => FUNDED[asset] ?? [], + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: readTxOut(POLICY_ASSET, COVENANT_SATS), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }, + ); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("150000"); + expect(result.reason).toContain(TOKEN); + } + }); +}); + +/** + * Where a funded input lands, which the document decides and the wallet no longer does. + * + * A covenant introspects positions, so where an input sits is part of what the document says. + * The wallet used to add every covenant first and its own outputs after, which meant an input it + * supplies could only ever land after every covenant — and the published contracts fix one at + * index zero. Now the stated positions are taken first and everything else fills what is left, + * so the order comes from the document wherever it states one. + */ +describe("a funded input at a stated position", () => { + function withStatedIndex(at: number, alsoOnCovenant?: number) { + const document = structuredClone(request.manifest) as unknown as { + actions: { Receive: { inputs: Record<string, unknown>[] } }; + }; + const payment = document.actions.Receive.inputs.find((input) => input.id === "payment_in"); + const covenant = document.actions.Receive.inputs.find((input) => input.id === "p2pk_in"); + + if (!payment || !covenant) { + throw new Error("the fixture declares no payment_in beside its covenant"); + } + + payment.required_index = at; + + if (alsoOnCovenant !== undefined) { + covenant.required_index = alsoOnCovenant; + } + + return { ...request, manifest: document as unknown as Record<string, unknown> }; + } + + function review(at: number, holdings: Record<string, SelectableUtxo[]>, alsoOnCovenant?: number) { + return reviewManifestAction(withStatedIndex(at, alsoOnCovenant), { + accountLabel: "liquid:testnet account 0", + compile: () => ({ address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }), + compilerVersion: "0.6.0", + fundingUtxos: holdings[POLICY_ASSET] ?? [], + holdingsOf: (asset) => holdings[asset] ?? [], + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: readTxOut(POLICY_ASSET, COVENANT_SATS), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }); + } + + /** The transaction's inputs by name: a covenant by its id, one of the wallet's by its txid. */ + async function inputsOf( + at: number, + holdings: Record<string, SelectableUtxo[]>, + ): Promise<string[]> { + const result = await review(at, holdings); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + return result.inputOrder.map((planned) => + planned.source === "covenant" ? planned.covenant.id : planned.utxo.txid.replace(/^0+/, ""), + ); + } + + // One covenant, then the output chosen for the fee, then the payment: the third input, + // exactly where the document says it must be and where the wallet would have put it anyway. + test("lands after the covenants and after the assets declared before it", async () => { + expect(await inputsOf(2, FUNDED)).toEqual(["p2pk_in", "c", "d"]); + }); + + // The case the wallet used to refuse outright. Nothing about a covenant makes it the first + // input; the published contracts that fix an index fix it on an input the wallet supplies, + // and one of them fixes it at zero. + test("lands ahead of the covenant when the document says it goes first", async () => { + expect(await inputsOf(0, FUNDED)).toEqual(["d", "p2pk_in", "c"]); + }); + + test("and between the covenant and the fee when the document says it goes second", async () => { + expect(await inputsOf(1, FUNDED)).toEqual(["p2pk_in", "d", "c"]); + }); + + // Two outputs chosen for the asset declared before it used to push it along by one and be + // refused for it. What the wallet chooses for an input nothing states a position for is the + // wallet's own business, so those two go around the one the document did place. + test("keeps its place when the asset before it takes more than one output", async () => { + expect( + await inputsOf(2, { ...FUNDED, [POLICY_ASSET]: [utxo("900", "c"), utxo("880", "e")] }), + ).toEqual(["p2pk_in", "c", "d", "e"]); + }); + + test("is refused by name when it asks for a place past the end of the transaction", async () => { + const result = await review(7, FUNDED); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reject).toBe("unbuildable-position"); + expect(result.reason).toContain("payment_in"); + } + }); + + // Placing is not a way to stop refusing. Two inputs cannot both be input one, in any order, + // and the one that does not get it is named rather than quietly moved. + test("and by name when the covenant claims the same place it does", async () => { + const result = await review(1, FUNDED, 1); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reject).toBe("unbuildable-position"); + expect(result.reason).toContain("payment_in"); + } + }); +}); + +// A wallet answers "what do I hold in this asset" from its own snapshot, building the list +// fresh each time it is asked. Two answers to the same question therefore share no identity, +// and anything comparing outputs by identity across two of them is comparing nothing. +describe("a wallet that describes its outputs anew each time it is asked", () => { + /** + * The swap with the payment input also minting a token. + * + * This is the shape the published lending protocol uses: the input carrying an asset is the + * same input the asset id is derived from. It makes the wallet ask for that asset's outputs + * twice — once to choose the one an issuance commits to, once to fund the rest. + */ + function issuingRequest() { + const document = structuredClone(request.manifest) as unknown as { + actions: { + Receive: { inputs: Record<string, unknown>[]; outputs: Record<string, unknown>[] }; + }; + }; + const payment = document.actions.Receive.inputs.find((input) => input.id === "payment_in"); + + if (!payment) { + throw new Error("the fixture declares no payment_in"); + } + + payment.issuance = { asset_amount_sat: 1, inflation_amount_sat: 0, kind: "new" }; + payment.on_resolved = { set: { "instance.MINTED": "asset" } }; + document.actions.Receive.outputs.push({ + amount_sat: 1, + asset: "instance.MINTED", + confidential: false, + description: "The unit this action created.", + destination: "wallet", + id: "minted_out", + }); + + return { ...request, manifest: document as unknown as Record<string, unknown> }; + } + + test("spends the output an issuance committed to exactly once", async () => { + const result = await reviewManifestAction(issuingRequest(), { + accountLabel: "liquid:testnet account 0", + compile: () => ({ address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }), + compilerVersion: "0.6.0", + fundingUtxos: FUNDED[POLICY_ASSET] ?? [], + // Two outputs in the payment asset, so the one an issuance commits to does not cover + // the payment on its own and the wallet has to come back for more of the same asset. + // Both described anew, which is what a wallet does: compared by identity, the output + // already committed to looks like one nobody has taken, and is taken again. + holdingsOf: (asset) => + (asset === TOKEN ? [utxo("200000", "d"), utxo("150000", "e")] : (FUNDED[asset] ?? [])).map( + again, + ), + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: readTxOut(POLICY_ASSET, COVENANT_SATS), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + const outpoints = result.selected.map((selected) => `${selected.txid}:${selected.vout}`); + + expect(result.issuances.length).toBe(1); + expect(new Set(outpoints).size).toBe(outpoints.length); + }); + + test("is asked once per asset, and spends each output once", async () => { + let asked = 0; + + const result = await reviewManifestAction(request, { + accountLabel: "liquid:testnet account 0", + compile: () => ({ address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }), + compilerVersion: "0.6.0", + fundingUtxos: FUNDED[POLICY_ASSET] ?? [], + holdingsOf: (asset) => { + asked += 1; + + return (FUNDED[asset] ?? []).map(again); + }, + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: readTxOut(POLICY_ASSET, COVENANT_SATS), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }); + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + const outpoints = result.selected.map((selected) => `${selected.txid}:${selected.vout}`); + + expect(new Set(outpoints).size).toBe(outpoints.length); + expect(asked).toBe(1); + }); +}); + +describe("what the chain says a covenant holds", () => { + // The document says the covenant is in one asset. If the output it names holds another, + // funding the stated one from the wallet and stranding what is really there is the one + // outcome nobody wants, so the disagreement is a refusal. + test("disagreeing with what the action declares is refused by name", async () => { + const document = structuredClone(request.manifest) as unknown as { + actions: { Receive: { inputs: Record<string, unknown>[] } }; + }; + const [covenant] = document.actions.Receive.inputs; + + if (!covenant) { + throw new Error("the fixture's Receive action declares no inputs"); + } + + covenant.asset = "params.token"; + + const result = await reviewManifestAction( + { ...request, manifest: document as unknown as Record<string, unknown> }, + { + accountLabel: "liquid:testnet account 0", + compile: () => ({ address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }), + compilerVersion: "0.6.0", + fundingUtxos: FUNDED[POLICY_ASSET] ?? [], + holdingsOf: (asset) => FUNDED[asset] ?? [], + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: readTxOut(POLICY_ASSET, COVENANT_SATS), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }, + ); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reject).toBe("foreign-asset"); + expect(result.reason).toContain("p2pk_in"); + } + }); +}); diff --git a/packages/tx-manifest/src/review/valueWiredCovenant.test.ts b/packages/tx-manifest/src/review/valueWiredCovenant.test.ts new file mode 100644 index 0000000..8626533 --- /dev/null +++ b/packages/tx-manifest/src/review/valueWiredCovenant.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import lendingManifest from "../__fixtures__/current/lending_v3.manifest.json"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import { isRefusal, reviewManifestAction } from "./index"; + +/** + * A live protocol's action reviewed end to end, where the covenant it spends is parameterised + * by values rather than by names. + * + * `covenants/valueWiredCovenant.test.ts` proves the covenant seam encodes those values from the + * type their contract declares. This proves the seam is *reached* — that a wallet calling + * `reviewManifestAction`, which is the only thing standing between a request and a signature, + * gets an action reviewed rather than refused. A capability the review path never calls is not + * delivered, however well it is tested on its own. + * + * The contracts are compiled by a substitute, as everywhere in this package. What the real + * compiler makes of the same arguments is `adapters/smplx/valueWiredCovenant.test.ts`. + */ + +const here = dirname(fileURLToPath(import.meta.url)); + +function contract(name: string): string { + return readFileSync(join(here, "../__fixtures__/contracts", name), "utf8"); +} + +const SOURCES = { + "./asset_auth.simf": contract("asset_auth.simf"), + "./asset_auth_vault.simf": contract("asset_auth_vault.simf"), + "./issuance_factory.simf": contract("issuance_factory.simf"), + "./lending.simf": contract("lending.simf"), + "./script_auth.simf": contract("script_auth.simf"), +}; + +/** + * The compiler's answer for the contracts this reaches, keyed by their source text. + * + * By text rather than by path because that is what the seam is handed: a wallet is asked what a + * contract declares, and the contract is the source it was given, not a name it might be filed + * under. Pinned here so this file needs no compiler; the adapter's test asserts the compiler + * still says it. + */ +const DECLARED: Record<string, Record<string, string>> = { + [SOURCES["./asset_auth.simf"]]: { + ASSET_AMOUNT: "u64", + ASSET_ID: "u256", + WITH_ASSET_BURN: "bool", + }, + [SOURCES["./asset_auth_vault.simf"]]: { + FINALIZED_VAULT_COV_HASH: "u256", + IS_ACTIVE: "bool", + KEEPER_AUTH_ASSET_AMOUNT: "u64", + KEEPER_AUTH_ASSET_ID: "u256", + SUPPLIER_AUTH_ASSET_ID: "u256", + VAULT_ASSET_ID: "u256", + WITH_KEEPER_ASSET_BURN: "bool", + WITH_SUPPLIER_ASSET_BURN: "bool", + }, +}; + +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const asset = (byte: string) => byte.repeat(32); +const PRINCIPAL_ASSET = asset("a1"); +const BORROWER_NFT = asset("b1"); +const COLLATERAL_ASSET = asset("c1"); +const LENDER_NFT = asset("d1"); +const DERIVED_SCRIPT = `5120${"11".repeat(32)}`; + +/** + * The covenant UTXOs this deployment's state file lists, and what the chain says each holds. + * + * One answer per outpoint rather than one answer for every read. Each covenant an action spends + * is checked against what is actually at its own outpoint, so a single answer for all of them + * only survives while one covenant per action is ever reached — which is what a leaf nothing + * could encode was hiding. + */ +const SPENDABLE = { + lender_nft_script_auth: { amountSats: "1", rawAssetId: LENDER_NFT, txid: "e".repeat(64) }, + lending_collateral: { amountSats: "100000", rawAssetId: COLLATERAL_ASSET, txid: "d".repeat(64) }, + lending_collateral_active: { + amountSats: "100000", + rawAssetId: COLLATERAL_ASSET, + txid: "c".repeat(64), + }, + principal_asset_auth: { amountSats: "50000", rawAssetId: PRINCIPAL_ASSET, txid: "b".repeat(64) }, +}; + +const HELD = new Map( + Object.values(SPENDABLE).map(({ amountSats, rawAssetId, txid }) => [ + txid, + { amountSats, rawAssetId }, + ]), +); + +/** This deployment's fields, as an activated loan records them. */ +const FIELDS = { + BORROWER_NFT_ASSET_ID: BORROWER_NFT, + COLLATERAL_AMOUNT: "100000", + COLLATERAL_ASSET_ID: COLLATERAL_ASSET, + CURRENT_DEBT: "52500", + FACTORY_ASSET_ID: asset("f1"), + FINALIZED_LENDER_VAULT_COV_HASH: asset("11"), + FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH: asset("33"), + ISSUING_UTXOS_COUNT: "2", + LENDER_NFT_ASSET_ID: LENDER_NFT, + LENDER_VAULT_COV_HASH: asset("22"), + LENDING_COV_SCRIPT_HASH: asset("66"), + LOAN_EXPIRATION_TIME: "1900000000", + PRINCIPAL_AMOUNT: "50000", + PRINCIPAL_ASSET_ID: PRINCIPAL_ASSET, + PRINCIPAL_INTEREST_RATE: "500", + PRINCIPAL_OUTPUT_SCRIPT_HASH: asset("55"), + PROTOCOL_FEE_KEEPER_ASSET_ID: asset("e1"), + PROTOCOL_FEE_VAULT_COV_HASH: asset("44"), + REISSUANCE_FLAGS: "0", + ZERO_HASH: "00".repeat(32), +}; + +function request(action: string): ParsedLiquidProcessCtParams { + return { + action, + broadcast: false, + contractSources: SOURCES, + instance: { instance: { class: "lending_contract", fields: FIELDS } }, + manifest: lendingManifest as unknown as Record<string, unknown>, + params: {}, + state: { + utxos: Object.entries(SPENDABLE).map(([utxoType, { txid }]) => ({ + txid, + utxo_type: utxoType, + vout: 0, + })), + }, + } as unknown as ParsedLiquidProcessCtParams; +} + +/** Reviews one action, with the contract-declaration seam filled or deliberately left empty. */ +function review( + action: string, + { declarations, readChainTip }: { declarations: boolean; readChainTip?: () => Promise<number> }, +) { + const compiled: { argumentsJson: string; extraLeavesJson: string; source: string }[] = []; + const funding = [ + { amount: "100000000", spendable: true, txid: "1".repeat(64), txOut: "00", vout: 0 }, + ]; + const single = [{ amount: "1", spendable: true, txid: "9".repeat(64), txOut: "00", vout: 1 }]; + + return { + compiled, + reviewed: reviewManifestAction(request(action), { + accountLabel: "liquid account 0", + compile: (input) => { + compiled.push({ + argumentsJson: input.argumentsJson, + extraLeavesJson: input.extraLeavesJson, + source: input.source, + }); + + return { address: "ex1p_derived", scriptPubKeyHex: DERIVED_SCRIPT }; + }, + compilerVersion: "0.6.0", + ...(declarations ? { contractParamTypes: (source: string) => DECLARED[source] ?? {} } : {}), + fundingUtxos: funding, + holdingsOf: (wanted: string) => + wanted === BORROWER_NFT || wanted === LENDER_NFT ? single : funding, + network: "liquid", + policyAsset: POLICY_ASSET, + ...(readChainTip ? { readChainTip } : {}), + readFeeRate: async () => 1000, + readTxOut: async (outpoint: { txid: string }) => ({ + ...(HELD.get(outpoint.txid) ?? { amountSats: "50000", rawAssetId: PRINCIPAL_ASSET }), + scriptPubKeyHex: DERIVED_SCRIPT, + txOutHex: "00", + }), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: `0014${"11".repeat(20)}`, + }), + }; +} + +/** Whatever compile carried this argument, whichever covenant it belonged to. */ +function argumentFor(calls: { argumentsJson: string }[], name: string): unknown { + for (const call of calls) { + const parsed = JSON.parse(call.argumentsJson) as Record<string, unknown>; + + if (name in parsed) { + return parsed[name]; + } + } + + return undefined; +} + +describe("claiming the principal, through the review a wallet actually runs", () => { + test("is refused when nothing says what its contract declares", async () => { + const { reviewed } = review("ClaimPrincipal", { declarations: false }); + const result = await reviewed; + + expect(isRefusal(result)).toBe(true); + expect(isRefusal(result) ? result.reason : "").toContain("ASSET_AMOUNT"); + }); + + test("and is reviewed once the contract's own declarations reach it", async () => { + const { reviewed } = review("ClaimPrincipal", { declarations: true }); + + expect(isRefusal(await reviewed)).toBe(false); + }); + + /** + * The two values that had no type. A count of one is written as decimal at the width the + * contract declared, and a flag as the word the compiler reads — neither inferred from how + * it looks. + */ + test("with the count and the flag typed by the contract rather than by their shape", async () => { + const { compiled, reviewed } = review("ClaimPrincipal", { declarations: true }); + + await reviewed; + + expect(argumentFor(compiled, "ASSET_AMOUNT")).toEqual({ type: "u64", value: "1" }); + expect(argumentFor(compiled, "WITH_ASSET_BURN")).toEqual({ type: "bool", value: "false" }); + }); + + test("and the asset id beside them still turned round the way a covenant reads one", async () => { + const { compiled, reviewed } = review("ClaimPrincipal", { declarations: true }); + + await reviewed; + + const committed = (BORROWER_NFT.match(/../g) ?? []).toReversed().join(""); + + expect(argumentFor(compiled, "ASSET_ID")).toEqual({ type: "u256", value: `0x${committed}` }); + }); +}); + +/** + * The leaf that used to stop all of this, now built. + * + * `lending_collateral` and `lending_collateral_active` write their extra taproot leaves as + * `{"type": "tapdata", "payload": [ … ]}`. This runtime read an extra leaf as a hex literal, a + * typed value or a state variable and never as a payload list, so the leaf carried no `value` + * and every action reaching one of those covenants was refused before a single contract was + * compiled. The two assertions below were written the other way round, against that refusal. + * + * The bytes are not this runtime's opinion of what a leaf should hold. The document writes a + * flag and a debt; the contract behind these covenants rebuilds both from `jet::tapdata_init()` + * and a thirty-two-byte add; the protocol's own Rust builder fills a `[0u8; 32]` slot the same + * way, `slot[31]` for the flag and `slot[24..32]` for the debt big-endian. + */ +describe("the collateral covenant a lending offer lives in", () => { + test("is built now, with its flag and its debt as the leaves the contract hashes", async () => { + const { compiled, reviewed } = review("RepayLoan", { declarations: true }); + + await reviewed; + + expect(JSON.parse(compiled[0]?.extraLeavesJson ?? "[]")).toEqual([ + `${"00".repeat(31)}01`, + `${"00".repeat(30)}cd14`, + ]); + }); + + /** The two finalised vaults are reached only by `RepayLoan`, and only past that leaf. */ + test("and repaying now reaches the two finalised vaults it stopped in front of", async () => { + const { compiled, reviewed } = review("RepayLoan", { declarations: true }); + + await reviewed; + + expect(compiled).toHaveLength(3); + expect( + compiled.filter((call) => call.source === SOURCES["./asset_auth_vault.simf"]), + ).toHaveLength(2); + }); + + test("accepting an offer is reviewed end to end", async () => { + expect(isRefusal(await review("AcceptOffer", { declarations: true }).reviewed)).toBe(false); + }); + + test("and so is cancelling one", async () => { + expect(isRefusal(await review("CancelOffer", { declarations: true }).reviewed)).toBe(false); + }); + + /** + * The order the contract behind this covenant reads, now the one the wallet builds. + * + * Repaying is the action whose document puts an input the wallet supplies at index zero and + * the covenant it spends at index one. The wallet's own habit — every covenant first, then + * whatever it chose — cannot produce that, and it used to refuse the action for it. The + * contract is not being generous here: it reads input zero for the token that authorises the + * repayment and asserts its own index is one, so the habit builds a transaction it rejects. + */ + test("and repaying, whose document puts one of the wallet's own inputs first", async () => { + const result = await review("RepayLoan", { declarations: true }).reviewed; + + expect(isRefusal(result) ? result.reason : "").toBe(""); + expect(isRefusal(result)).toBe(false); + }); + + test("with the wallet's own input built ahead of the covenant, as the document states", async () => { + const result = await review("RepayLoan", { declarations: true }).reviewed; + + if (isRefusal(result)) { + throw new Error(result.reason); + } + + expect(result.inputOrder.map((planned) => planned.source)).toEqual([ + "wallet", + "covenant", + "wallet", + "wallet", + ]); + expect(result.inputOrder[1]).toMatchObject({ covenant: { id: "active_offer_in" } }); + // The borrower's NFT: the one output the wallet holds in that asset, at index zero. + expect(result.inputOrder[0]).toMatchObject({ utxo: { txid: "9".repeat(64) } }); + }); +}); + +/** + * What a live protocol's actions now return to the person who performed them. + * + * These four are the ones that review end to end, so they are the only ones that can answer + * this from the review a wallet actually runs rather than from the resolver agreeing with + * itself. Every one of them declares change and says nothing about it, which on this network + * used to mean hidden — and a contract action can be funded only by outputs that hide nothing, + * so each of these transactions used to hand back money the next action could not reach. + * + * They hide nothing at all now. Two of them return change in the protocol's own asset as well + * as the network's, and both come back spendable, because a token's change starves the next + * action exactly as the network asset's does. + */ +describe("what these actions return to the person who performed them", () => { + const PUBLISHED = + "nothing says otherwise and this network hides an output by default, and this " + + "wallet publishes it anyway so your next action can spend it"; + + async function reviewed(action: string) { + const result = await review(action, { declarations: true }).reviewed; + + if (isRefusal(result)) { + throw new Error(`${action}: ${result.reason}`); + } + + return result; + } + + for (const action of ["AcceptOffer", "CancelOffer", "ClaimPrincipal", "RepayLoan"]) { + test(`${action} hides nothing, and says the change is published`, async () => { + const result = await reviewed(action); + + expect(result.changeBlinded).toBe(false); + expect(result.outputs.filter((output) => output.blinded)).toEqual([]); + expect(result.confirmation.hiddenAmounts).toEqual([]); + // The network asset's change is appended by the signing module, so it is named rather + // than given a document's id, and it is last. + expect(result.confirmation.publishedAmounts.at(-1)).toMatchObject({ + id: { value: "change" }, + reason: { value: PUBLISHED }, + }); + }); + } + + // The deviation is about a contract action's own change, not about the network's asset. A + // loan is repaid and accepted in a token, and the change in that token is built by this + // wallet at the position the document declares — hidden, it would starve the next action in + // exactly the way the network asset's change would. + for (const action of ["AcceptOffer", "RepayLoan"]) { + test(`${action} publishes its change in the protocol's own asset too`, async () => { + const result = await reviewed(action); + + expect(result.outputs).toContainEqual( + expect.objectContaining({ blinded: false, id: "principal_change", overrode: "chain" }), + ); + expect(result.confirmation.publishedAmounts.map((row) => row.id.value)).toEqual([ + "principal_change", + "change", + ]); + }); + } +}); + +/** + * A covenant branch guarded by a lock height. + * + * `check_lock_height` is read from the transaction's own locktime, and no document in the + * corpus states one — the height a spend becomes valid at is a fact about the chain rather + * than about the protocol. So the wallet answers with where the chain is, and reports it, or + * the branch is unsatisfiable and fails at execution for a reason nothing here would explain. + */ +describe("the height a spend declares", () => { + // Exercised through an action this vendored document actually declares. The deployed + // document's liquidation is the branch that needs a height, and it is one of the two + // actions its own protocol's authors never published — so what is checked here is the + // rule rather than that protocol: an action spending a covenant reports the chain's height. + test("is the chain's own, for an action that spends a covenant", async () => { + const result = await review("CancelOffer", { + declarations: true, + readChainTip: async () => 2_580_990, + }).reviewed; + + if (isRefusal(result)) throw new Error(`refused: ${result.reason}`); + + expect(result.locktimeHeight).toBe(2_580_990); + }); + + test("is absent when no reader was supplied, rather than guessed", async () => { + const result = await review("CancelOffer", { declarations: true }).reviewed; + + expect(!isRefusal(result) && "locktimeHeight" in result).toBe(false); + }); + + test("is absent when the reader fails, because one unread height is not a refusal", async () => { + const result = await review("CancelOffer", { + declarations: true, + readChainTip: async () => { + throw new Error("no answer"); + }, + }).reviewed; + + expect(!isRefusal(result) && "locktimeHeight" in result).toBe(false); + }); +}); diff --git a/scripts/checkSmplxWasm.ts b/scripts/checkSmplxWasm.ts new file mode 100644 index 0000000..35fa546 --- /dev/null +++ b/scripts/checkSmplxWasm.ts @@ -0,0 +1,47 @@ +/** + * Is the wasm the build will bundle the wasm that was last compiled? + * + * `smplx-wasm` is a `file:` dependency, and the installer hard-links its JavaScript glue while + * copying the module itself. So a rebuilt artifact reaches the bundle half-updated: the glue + * carries the new signatures and the module carries the old code, which ignores every argument + * the old signature did not have. Nothing fails — the call is made, the extra values are + * dropped, and the wallet builds a different contract than the one it verified. + * + * That cost an afternoon once. This makes it a one-line failure instead. + */ + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const built = join(root, "smplx/crates/wasm/pkg/smplx_wasm_bg.wasm"); +const installed = join(root, "node_modules/smplx-wasm/smplx_wasm_bg.wasm"); + +function digestOf(path: string): string { + try { + return createHash("sha256").update(readFileSync(path)).digest("hex"); + } catch { + return ""; + } +} + +const builtDigest = digestOf(built); +const installedDigest = digestOf(installed); + +if (builtDigest === "") { + console.error(`No compiled smplx wasm at ${built}. Build it with: bun run build:wasm`); + process.exit(1); +} + +if (builtDigest !== installedDigest) { + console.error("The installed smplx wasm is not the one that was last compiled."); + console.error(` compiled : ${builtDigest.slice(0, 16)} ${built}`); + console.error(` installed: ${installedDigest.slice(0, 16) || "(absent)"} ${installed}`); + console.error(""); + console.error("The glue is hard-linked and the module is copied, so a rebuild updates only"); + console.error("half of it and every new argument is silently dropped. Fix with:"); + console.error(" bun install --force"); + process.exit(1); +} diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 2112cff..0000000 --- a/skills-lock.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "version": 1, - "skills": { - "align-project-knowledge": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "556e5652ba061dd4220353769a02b67e4edb0d2b929208177523a3b826bd54f1" - }, - "analyze-with-graphify": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "f63348d1beb0145770f1436fb7299ef01d3702ef8f7f43b78c120f2bf4ac7297" - }, - "curate-engineering-knowledge": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "e4d658327bf87851941d0eb0130c7d15e72562e3ff9c6d51ef7ceb10c9383984" - }, - "curate-product-knowledge": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "4e9a863ce20536e073744042e22159cc8a38c667f4d572729ed35db43b6d6530" - }, - "curate-project-knowledge": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "f79eb1b6dc2192f8385bd5f6a0f1d5515603465112ea3c34c22dee65e813c387" - }, - "explore-project-knowledge": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "02a871fdc9ccc4086df980b6c29f31792a4c8246155cf57980a898e26a87c92f" - }, - "implement-work-item": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "a21a8c78d3371e81db35cbe1f0d52475de1dadb56a772ee9ca76913cd56cd403" - }, - "manage-project-work": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "f51cef85094f0707199c5f62b2474e4333d67c5588bd6bc4d2c289070112e65c" - }, - "qmd": { - "source": "/Users/lukachisama/.bun/install/global/node_modules/@tobilu/qmd/skills/qmd", - "sourceType": "local", - "computedHash": "c21e27d9d9de60158103055495ed0075eeb295df6c5bc2d8e795b948173cb6a8" - }, - "setup-workflow-environment": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "746ab468dd038e38a0b4e8d53cda2e2502b9997695e9833d60b93e3e8ca60d7f" - }, - "shape-project-direction": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "7e0b6c9535c4d1faf95c340b1c1b3b9c8e68d3e9dbc63ba77b56df46b437e192" - }, - "specify-project-change": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "acbe1a8faad0aa2287169bc51924fe28f3c9f3200e4939b9aa508546cbff301e" - }, - "split-project-change": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "06d265d98ad7a9e9383819d98c479d09f2141f67d5720ef9b1800622796fe811" - }, - "verify-knowledge-quality": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "d825cc63dfc8eac2a1116567d1a3441dfdf5559fe10354bb7967eecc3e6143d9" - }, - "verify-project-work": { - "source": "/Users/lukachisama/Documents/agent-skills/workflow", - "sourceType": "local", - "computedHash": "0b0bba317ec806d47ff4630b6e0afc62ab910c993894916a0bfd1c8fd0f2a35a" - } - } -} diff --git a/smplx b/smplx index e777b70..783ef77 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit e777b70b483d489495c0de74612c12be3142398d +Subproject commit 783ef7794d9dccdb7568da6a956151a2ae5362af From 65d71c29e67542ac7c121d0d6fe65bebe2da94ab Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Thu, 20 Aug 2026 15:33:27 +0300 Subject: [PATCH 123/124] chore(smplx): follow the covenant-spend work through its review fixes The submodule pointed at a commit that no longer exists: upstream CI refused the branch on rustfmt and two clippy lints, the fix was amended into the same commit, and force-pushing it left this pointer dangling for anyone cloning with submodules. No behaviour changes. A nested if becomes a let-chain, two closures become the method references they wrapped, and four places are reformatted the way the project's rustfmt formats them. --- smplx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smplx b/smplx index 783ef77..ee09113 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit 783ef7794d9dccdb7568da6a956151a2ae5362af +Subproject commit ee09113bc24ea8c1443fc64dfae3f2af2dbf99f1 From f913b067d953b7e4ef82bc5e8d5922db9ff84674 Mon Sep 17 00:00:00 2001 From: lukachi <lukachisama@gmail.com> Date: Fri, 21 Aug 2026 15:43:33 +0300 Subject: [PATCH 124/124] Follow smplx's transaction-level sequence, and refuse what one input alone cannot carry Upstream took the sequence off each input and put it on the transaction: the module now writes one value onto every input that declares none, and the four add-input bindings no longer accept one. Passing the old argument list would have shifted every argument after it, which is how a covenant came to be built without its leaves once already. What an action declares per input therefore has to collapse to a single value or be refused. A sequence carrying BIP68's disable bit constrains no input and only enables the transaction's own locktime, so it carries onto all of them unchanged, which is what every such declaration in the published corpus is for. A relative timelock cannot: BIP68 measures one against the age of the input it sits on, so writing it onto the outputs funding the transaction locks those against their own age instead, and the chain rejects that on broadcast far from anything explaining it. Two inputs asking for different sequences cannot both be honoured either. Both are refused in the review, before the person is asked. The submodule follows the amended branch at be24fed. --- .../adapters/smplx/loadSmplxWasm.test.ts | 14 +--- .../index.test.ts | 71 ++++++++++++++++++- .../processConfidentialTransaction/index.ts | 23 +++--- .../src/evaluation/inputRules.test.ts | 48 ++++++++++++- .../tx-manifest/src/evaluation/inputRules.ts | 57 +++++++++++++++ packages/tx-manifest/src/review/index.ts | 25 ++++++- smplx | 2 +- 7 files changed, 211 insertions(+), 29 deletions(-) diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 413afc7..404e471 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -848,7 +848,7 @@ describe("a transaction balances against the fee that is charged", () => { const builder = new bindings.TransactionBuilder(); try { - builder.addWalletInput(TXID, 0, txOut(funded, signer.scriptPubKeyHex()), undefined); + builder.addWalletInput(TXID, 0, txOut(funded, signer.scriptPubKeyHex())); builder.addOutput(signer.scriptPubKeyHex(), paid, POLICY_ASSET); builder.addChange(signer.scriptPubKeyHex()); @@ -924,18 +924,10 @@ describe("what an issuance adds to the input carrying it", () => { if (issuing) { builder - .addWalletIssuanceInput( - TXID, - 0, - txOut(100_000n, script), - 1_000n, - 0n, - undefined, - undefined, - ) + .addWalletIssuanceInput(TXID, 0, txOut(100_000n, script), 1_000n, 0n, undefined) .free(); } else { - builder.addWalletInput(TXID, 0, txOut(100_000n, script), undefined); + builder.addWalletInput(TXID, 0, txOut(100_000n, script)); } builder.addOutput(script, 10_000n, ISSUING_POLICY_ASSET); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts index 1353def..82ed6da 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -221,6 +221,13 @@ type Recorded = { * through fails here rather than agreeing with itself. */ locktimeHeight?: number; + /** + * The sequence the builder was told every input carries. + * + * Read from the builder rather than from the review, so a method that stopped passing a + * declared sequence through fails here rather than agreeing with itself. + */ + sequence?: number; /** * Whether the builder was told to hide the change it returns. * @@ -301,6 +308,8 @@ function dependencies( changeBlinded = false; /** The height the method declared, so a transaction that stops declaring one shows here. */ locktimeHeight: number | undefined; + /** The sequence the method declared, so one that stops being passed shows here. */ + sequence: number | undefined; /** Each output as it was told, so the transaction it returns carries them. */ outputs: { blinded: boolean; script: string }[] = []; spends: { txid: string; vout: number }[] = []; @@ -312,6 +321,10 @@ function dependencies( this.locktimeHeight = height; recorded.locktimeHeight = height; } + setSequence(sequence: number) { + this.sequence = sequence; + recorded.sequence = sequence; + } addChange(scriptPubKeyHex: string, blindingKeyHex?: string) { requireHex("change script", scriptPubKeyHex); @@ -330,7 +343,6 @@ function dependencies( _argumentsJson: string | undefined, _witnessJson: string | undefined, _signatureWitness: string | undefined, - _sequence: number | undefined, extraLeavesJson: string | undefined, includeDebugSymbols: boolean | undefined, ) { @@ -1015,6 +1027,63 @@ function issuingManifest( return manifest; } +/** The same protocol with its funding input declaring a sequence. */ +function sequencedManifest(sequence: unknown) { + const manifest = structuredClone(p2pkManifest) as unknown as { + actions: { Pay: { inputs: Record<string, unknown>[] } }; + }; + const [funding] = manifest.actions.Pay.inputs; + + if (!funding) { + throw new Error("the fixture's Pay action declares no inputs"); + } + + funding.sequence = sequence; + + return manifest; +} + +// The module takes one sequence for the transaction and writes it onto every input that +// declares none, so a declaration either collapses to one value the whole transaction can +// carry or it is refused. Dropping one builds a transaction the protocol did not ask for, +// which the chain rejects on broadcast far from anything that explains it. +describe("the sequence the transaction declares", () => { + // 0xFFFFFFFE carries BIP68's disable bit, so it constrains no input and only enables the + // transaction's own locktime. That is what every such declaration in the corpus is for. + test("is handed to the module when the action declares one that constrains nothing", async () => { + const { method, recorded } = subject(); + + await method(params({ manifest: sequencedManifest(4_294_967_294) }), context()); + + expect(recorded.sequence).toBe(4_294_967_294); + }); + + test("and is left unset where the action declares nothing", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.sequence).toBeUndefined(); + }); + + // A relative timelock is measured against the age of the input carrying it, so writing one + // onto the outputs funding the transaction time-locks those too. Refused rather than built + // as something else. + test("but a relative timelock is refused, because it cannot be carried by one input alone", async () => { + const { method } = subject(); + + const failure = await method( + params({ manifest: sequencedManifest({ relative_blocks: 6 }) }), + context(), + ).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string } }, + ); + + expect(failure?.data?.reject).toBe("unimplemented-construct"); + }); +}); + // The asset an action creates is worked out while the document is read, from an output the // wallet commits to before anything else runs. Until now none of that reached the module, so // the wallet showed a person an asset and signed a transaction that created nothing. diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index 9694d2e..0c4e010 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -235,6 +235,15 @@ export const createProcessLiquidConfidentialTransaction = ( builder.setLocktimeHeight(review.locktimeHeight); } + // One sequence for the transaction, because that is what the module takes: it + // writes this onto every input that declares none. The review has already + // collapsed what the action declares into the single value this can be, or + // refused the action. Skipped where nothing was declared, which leaves every + // input at the module's own default. + if (review.sequence !== undefined) { + builder.setSequence(review.sequence); + } + try { // Which inputs create an asset, keyed by the output each one is derived // from. That outpoint is the only join both sides promise: the manifest @@ -330,7 +339,6 @@ export const createProcessLiquidConfidentialTransaction = ( issuance.assetAmountSats, issuance.inflationAmountSats, undefined, - sequenceFor(review, covenant.id), covenant.extraLeavesJson, covenant.includeDebugSymbols, ), @@ -353,7 +361,6 @@ export const createProcessLiquidConfidentialTransaction = ( covenant.argumentsJson, witness, covenant.signatureWitness, - sequenceFor(review, covenant.id), covenant.extraLeavesJson, covenant.includeDebugSymbols, ); @@ -374,7 +381,6 @@ export const createProcessLiquidConfidentialTransaction = ( issuance.assetAmountSats, issuance.inflationAmountSats, undefined, - undefined, ), ); } else { @@ -618,17 +624,6 @@ export const createProcessLiquidConfidentialTransaction = ( export const processLiquidConfidentialTransaction = createProcessLiquidConfidentialTransaction(); -/** - * The relative timelock this covenant input must carry, when its action declared one. - * - * A covenant can require the timelock rather than merely permit it, and the chain rejects a - * transaction built without one — so a declaration dropped here fails on broadcast, far from - * anything that explains it. - */ -function sequenceFor(review: ManifestReview, id: string): number | undefined { - return review.inputRules.find((rule) => rule.id === id)?.sequence; -} - /** One output, written the same way on both sides of a comparison. */ function outpointKey(txid: string, vout: number): string { return `${txid}:${vout}`; diff --git a/packages/tx-manifest/src/evaluation/inputRules.test.ts b/packages/tx-manifest/src/evaluation/inputRules.test.ts index 4d41acd..3bcebd7 100644 --- a/packages/tx-manifest/src/evaluation/inputRules.test.ts +++ b/packages/tx-manifest/src/evaluation/inputRules.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from "bun:test"; import { type NormalisedAction, normaliseManifest } from "../document/normalise"; import type { ReferenceScope } from "../document/references"; import { encodeDataParts } from "./encode"; -import { resolveInputRules } from "./inputRules"; +import { resolveInputRules, transactionSequence } from "./inputRules"; // Three constructs the approved specification names as in scope because lending_v3 and // last_will cannot be built without them. The shapes below are the ones those manifests @@ -119,3 +119,49 @@ describe("object-form data", () => { }); }); }); + +// The module takes one sequence for the whole transaction and writes it onto every input that +// declares none, so what an action declares per input has to collapse to one value or be +// refused. Both halves are here: what carries, and what cannot. +describe("the one sequence a transaction can carry", () => { + test("is nothing when no input declares one", () => { + expect(transactionSequence([{ id: "a" }, { id: "b" }])).toEqual({ ok: true, value: undefined }); + }); + + // The disable bit is set, so it constrains no input and only enables the transaction's own + // locktime. Landing on the wallet's funding inputs changes nothing about them. + test("is the declared value when it constrains no input", () => { + expect(transactionSequence([{ id: "a", sequence: 4_294_967_294 }])).toEqual({ + ok: true, + value: 4_294_967_294, + }); + }); + + test("and stays that value when every declaration agrees", () => { + expect( + transactionSequence([ + { id: "a", sequence: 4_294_967_294 }, + { id: "b", sequence: 4_294_967_294 }, + ]), + ).toEqual({ ok: true, value: 4_294_967_294 }); + }); + + test("but two inputs asking for different sequences cannot both be honoured", () => { + const result = transactionSequence([ + { id: "a", sequence: 4_294_967_294 }, + { id: "b", sequence: 4_294_967_293 }, + ]); + + expect(result.ok).toBe(false); + expect(!result.ok && result.reason).toContain("cannot give two inputs different ones"); + }); + + // BIP68 measures a relative timelock against the age of the input it sits on, so writing + // one onto the outputs funding the transaction locks those against their own age instead. + test("and a relative timelock cannot be carried by the transaction at all", () => { + const result = transactionSequence([{ id: "vault_in", sequence: 144 }]); + + expect(result.ok).toBe(false); + expect(!result.ok && result.reason).toContain("relative timelock"); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/inputRules.ts b/packages/tx-manifest/src/evaluation/inputRules.ts index 8105a79..993926c 100644 --- a/packages/tx-manifest/src/evaluation/inputRules.ts +++ b/packages/tx-manifest/src/evaluation/inputRules.ts @@ -145,3 +145,60 @@ function resolveFromAddress( ? { ok: true, value: found.value } : { ok: false, reason: `${declared} is not an address.` }; } + +/** BIP68's disable bit. A sequence at or above it imposes no relative timelock. */ +const SEQUENCE_TIMELOCK_DISABLED_FROM = 0x8000_0000; + +export type TransactionSequenceResult = + | { ok: false; reason: string } + | { ok: true; value: number | undefined }; + +/** + * The one sequence this transaction can carry, out of what its inputs declare. + * + * The signing module takes a sequence for the transaction and writes it onto every input + * that declares none, so a per-input sequence is not expressible: whatever is carried lands + * on the wallet's own funding inputs too. Two declarations that disagree cannot both be + * honoured, and dropping either builds a transaction the protocol did not ask for. + * + * A relative timelock cannot be carried at all. BIP68 measures one against the age of the + * input it sits on, so putting an action's timelock onto an output the wallet has just + * received makes the transaction invalid until that output has aged as well — a different + * transaction from the one declared, failing on broadcast rather than here. + * + * A sequence with the disable bit set constrains no input and only enables the transaction's + * own locktime, which is what every such declaration in the published corpus is for, so that + * one carries onto every input without changing what any of them require. + */ +export function transactionSequence(rules: InputRule[]): TransactionSequenceResult { + const declared = rules.filter((rule) => rule.sequence !== undefined); + const first = declared[0]; + + if (first?.sequence === undefined) { + return { ok: true, value: undefined }; + } + + const disagreeing = declared.find((rule) => rule.sequence !== first.sequence); + + if (disagreeing) { + return { + ok: false, + reason: + `Input ${first.id} asks for sequence ${first.sequence} and input ${disagreeing.id} ` + + `for ${disagreeing.sequence}. This wallet sets one sequence for the whole ` + + "transaction and cannot give two inputs different ones.", + }; + } + + if (first.sequence < SEQUENCE_TIMELOCK_DISABLED_FROM) { + return { + ok: false, + reason: + `Input ${first.id} asks for a relative timelock, and this wallet sets one sequence ` + + "for the whole transaction — which would time-lock the outputs funding it as well, " + + "against their own age rather than this input's.", + }; + } + + return { ok: true, value: first.sequence }; +} diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index fb5aba7..fe22e3b 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -37,7 +37,7 @@ import { withHookValues, } from "../evaluation/hooks"; import { type PlaceableInput, placeInputs } from "../evaluation/inputOrder"; -import { type InputRule, resolveInputRules } from "../evaluation/inputRules"; +import { type InputRule, resolveInputRules, transactionSequence } from "../evaluation/inputRules"; import { declaredIssuance, issuanceAttributes, @@ -299,6 +299,18 @@ export type ManifestReview = { inputOrder: PlannedInput[]; /** What each input must carry beyond its source, when the action says so. */ inputRules: InputRule[]; + /** + * The sequence every input of this transaction carries, when the action declares one. + * + * One value rather than one per input, because that is what the signing module takes: it + * writes this onto every input that declares none, the wallet's own funding inputs + * included. An action whose declarations cannot collapse to one value is refused rather + * than built, so this being present means every declaration in it agreed. + * + * Absent where the action declares nothing, which leaves every input at the module's own + * default and the transaction's locktime unenforced. + */ + sequence?: number; /** The wallet's own outputs that fund this, chosen by the wallet. */ selected: SelectableUtxo[]; }; @@ -858,6 +870,16 @@ export async function reviewManifestAction( return { reason: inputRules.reason, refused: true, reject: "document-fault" }; } + // The signing module takes one sequence for the transaction and writes it onto every input + // that declares none, so what the action declares per input has to collapse to a single + // value or be refused here. Refused before the person is asked rather than at signing: a + // declaration this wallet cannot carry is permanent, and nothing about the request fixes it. + const sequence = transactionSequence(inputRules.rules); + + if (!sequence.ok) { + return { reason: sequence.reason, refused: true, reject: "unimplemented-construct" }; + } + // The protocol's own rules about this action, checked once its amounts are known — a rule // comparing an amount cannot be checked before there is one. const failed = checkValidations(action, { ...scope, fee: estimatedFee }, notes); @@ -1236,6 +1258,7 @@ export async function reviewManifestAction( outputs, inputOrder: placement.order, inputRules: inputRules.rules, + ...(sequence.value === undefined ? {} : { sequence: sequence.value }), protocol: manifest.protocol ?? "", // Each asset's own outputs together, in the order the action declares the inputs that // need them, with the output an issuance is derived from first within its asset — each diff --git a/smplx b/smplx index ee09113..be24fed 160000 --- a/smplx +++ b/smplx @@ -1 +1 @@ -Subproject commit ee09113bc24ea8c1443fc64dfae3f2af2dbf99f1 +Subproject commit be24fed754134b29adb1de687e90ed6bdd063df7