From 5b45741fc072d78477da477913d094a910db030f Mon Sep 17 00:00:00 2001 From: AlexZhang Date: Tue, 18 Aug 2026 18:09:56 +0800 Subject: [PATCH 1/2] chore(specs): spec regression gate, capability spec + ADR for DM encryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the #306 invariants durable and mechanically enforced: - `knowledge/` workspace (`agent-spec init --workspace`) with ADR-001 "New direct messages are encrypted unless the target is a positively identified bot" (status: Accepted). - `specs/capabilities/dm-encryption.spec.md`: long-lived capability spec carrying Rules dm-enc-1/dm-enc-2 (7 scenarios incl. 3 property tests), `satisfies: [ADR-001]`; `agent-spec trace ADR-001 --gate` → honored. - `scripts/spec-guard.sh`: the single gate for pre-commit and CI — lint changed specs, structural guards (`specs/structure-guards.txt`), capability specs must fully pass + ADR liveness gate, changed task specs verified with the change set, all others as regression (tests only, `manual_test_*` skips tolerated). Mutation-checked: reverting the #306 fix turns the gate red at all three levels. - `.githooks/pre-commit` (opt-in via `git config core.hooksPath .githooks`), CI job `spec_gate` (`cargo test --lib` + spec-guard against the PR base), CODEOWNERS for `specs/**` and `knowledge/**`, CLAUDE.md docs. - `.gitignore`: track `.agent-spec/config.yaml`, keep runtime state ignored. Co-Authored-By: Claude Fable 5 --- .agent-spec/config.yaml | 7 + .githooks/pre-commit | 11 ++ .github/CODEOWNERS | 7 + .github/workflows/main.yml | 56 ++++++++ .gitignore | 3 +- CLAUDE.md | 13 ++ knowledge/context/README.md | 3 + .../decisions/00001-dm-encryption-default.md | 67 +++++++++ knowledge/decisions/README.md | 4 + knowledge/decisions/adr-template.md | 22 +++ knowledge/guidance/README.md | 5 + knowledge/guidance/guidance-template.md | 14 ++ knowledge/proposals/README.md | 4 + knowledge/proposals/lep-template.md | 88 ++++++++++++ knowledge/requirements/README.md | 13 ++ knowledge/requirements/req-template.md | 39 +++++ knowledge/standards/canon/artifact-types.md | 9 ++ scripts/spec-guard.sh | 136 ++++++++++++++++++ specs/capabilities/dm-encryption.spec.md | 73 ++++++++++ specs/structure-guards.txt | 6 + 20 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 .agent-spec/config.yaml create mode 100755 .githooks/pre-commit create mode 100644 .github/CODEOWNERS create mode 100644 knowledge/context/README.md create mode 100644 knowledge/decisions/00001-dm-encryption-default.md create mode 100644 knowledge/decisions/README.md create mode 100644 knowledge/decisions/adr-template.md create mode 100644 knowledge/guidance/README.md create mode 100644 knowledge/guidance/guidance-template.md create mode 100644 knowledge/proposals/README.md create mode 100644 knowledge/proposals/lep-template.md create mode 100644 knowledge/requirements/README.md create mode 100644 knowledge/requirements/req-template.md create mode 100644 knowledge/standards/canon/artifact-types.md create mode 100755 scripts/spec-guard.sh create mode 100644 specs/capabilities/dm-encryption.spec.md create mode 100644 specs/structure-guards.txt diff --git a/.agent-spec/config.yaml b/.agent-spec/config.yaml new file mode 100644 index 000000000..85bf5f61f --- /dev/null +++ b/.agent-spec/config.yaml @@ -0,0 +1,7 @@ +paths: + knowledge: knowledge + specs: specs +liveness: + gate: + violated: error + unproven: warning diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..b811d9913 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Enable once per clone: git config core.hooksPath .githooks +# Fast gate on every commit: lint changed specs + structural guards. +# Set SPEC_GUARD_FULL=1 to also run capability specs, ADR liveness and the +# skip-tolerant `agent-spec guard` (runs cargo tests; ~2-3 min). +set -e +if [ "${SPEC_GUARD_FULL:-0}" = "1" ]; then + exec scripts/spec-guard.sh --change-scope staged +else + exec scripts/spec-guard.sh --change-scope staged --fast +fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..7ffc4ff64 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# Contract Acceptance: changes to specs and knowledge (decisions/requirements) +# are reviewed as contracts, separately from code. Weakening or deleting a +# `Rule:` / `Tags: critical` scenario needs an explicit maintainer OK. +/specs/** @ZhangHanDong +/knowledge/** @ZhangHanDong +/scripts/spec-guard.sh @ZhangHanDong +/.githooks/** @ZhangHanDong diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32ab9dbfc..46d395898 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,10 @@ on: - Cargo.lock - Cargo.toml - rust-toolchain.toml + - specs/** + - knowledge/** + - scripts/** + - .githooks/** pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -29,6 +33,10 @@ on: - Cargo.lock - Cargo.toml - rust-toolchain.toml + - specs/** + - knowledge/** + - scripts/** + - .githooks/** # Prevent concurrent CI runs and cancel in-progress runs on new pushes concurrency: @@ -87,3 +95,51 @@ jobs: exit 1 } ' Cargo.toml + + # Spec regression gate: unit tests + agent-spec invariants. + # See scripts/spec-guard.sh for exactly what is (and is not) enforced. + spec_gate: + if: github.event.pull_request.draft == false + name: spec gate (cargo test + agent-spec) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # spec-guard diffs against the base branch + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libssl-dev libsqlite3-dev pkg-config llvm clang libclang-dev \ + libxcursor-dev libx11-dev libasound2-dev libpulse-dev \ + libwayland-dev libxkbcommon-dev + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + rustflags: "" + + - uses: Swatinem/rust-cache@v2 + with: + key: spec-gate-${{ hashFiles('Cargo.lock') }} + cache-all-crates: "true" + cache-on-failure: "true" + + - name: Install agent-spec + run: | + if ! command -v agent-spec >/dev/null 2>&1; then + cargo install agent-spec --locked + fi + agent-spec --version + + - name: Unit tests (lib) + # Doctests are excluded for now: see #301 / #311 for the doctest cleanup. + run: cargo test --lib + + - name: Spec guard + env: + BASE_REF: ${{ github.event.pull_request.base.ref || 'main' }} + run: | + git fetch origin "$BASE_REF" --depth=1 || true + scripts/spec-guard.sh --base "origin/$BASE_REF" + diff --git a/.gitignore b/.gitignore index 55f112ab4..0247a6ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,8 @@ proxychains.conf .mcp.json .playwright-mcp/ .superpowers/ -.agent-spec/ +.agent-spec/* +!.agent-spec/config.yaml ## local config backups / screenshots *.bak diff --git a/CLAUDE.md b/CLAUDE.md index 9379e2bcc..a9b64cb5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,3 +123,16 @@ Write the invariants BEFORE the code, then bind each one to tests so `agent-spec 7. Root-level files in `Allowed Changes` must be written as `./Cargo.toml` to match the boundary checker. `proptest` is an approved dev-dependency (see `specs/project.spec.md` → Decisions). Do not add fuzzing (`cargo-fuzz`) for decision logic; reserve it for byte-level parsing surfaces. + +### Spec regression gate + +`scripts/spec-guard.sh` is the single gate (pre-commit + CI job `spec_gate`): + +1. `agent-spec lint --min-score 0.7` on specs changed in the change set; +2. structural guards from `specs/structure-guards.txt` (`agent-spec check-structure`); +3. every `specs/capabilities/*.spec.md` must fully pass, and every ADR it `satisfies:` must trace as `honored` (`agent-spec trace --gate`); +4. changed task specs are verified WITH the change set (boundaries + tests); all other task specs are verified WITHOUT it (regression, `failed > 0` fails; `manual_test_*` skips are tolerated). + +Enable the hook once per clone: `git config core.hooksPath .githooks` (fast path by default; `SPEC_GUARD_FULL=1 git commit …` runs everything). + +**Never make a red gate green by editing a spec, a capability spec, or an ADR.** If a Rule genuinely must change, switch to authoring mode, change it in its own commit, and say so in the PR — `specs/**` and `knowledge/**` are CODEOWNERS-reviewed as contracts. Long-lived invariants live in `specs/capabilities/` + `knowledge/decisions/` (see `dm-encryption` / ADR-001 as the reference). diff --git a/knowledge/context/README.md b/knowledge/context/README.md new file mode 100644 index 000000000..15c6bac65 --- /dev/null +++ b/knowledge/context/README.md @@ -0,0 +1,3 @@ +# Context (free-form) + +Escape hatch: arbitrary agent-context. Served read-only, NOT linted, no schema. diff --git a/knowledge/decisions/00001-dm-encryption-default.md b/knowledge/decisions/00001-dm-encryption-default.md new file mode 100644 index 000000000..26e7db401 --- /dev/null +++ b/knowledge/decisions/00001-dm-encryption-default.md @@ -0,0 +1,67 @@ +--- +kind: decision +id: ADR-001 +title: "New direct messages are encrypted unless the target is a positively identified bot" +status: Accepted +--- + +## Context + +Robrix2 integrates appservice bots and agents (BotFather, `/listbots`-discovered +bots, room-bound bots, the Agent registry) that generally cannot participate in +end-to-end-encrypted rooms. Upstream Robrix creates every DM with +`Client::create_dm()` (encrypted). PR #81 made `should_create_encrypted_dm` +return `false` unconditionally so bots could always receive messages, which +silently created **unencrypted** DMs with ordinary users (issue #306). + +## Decision + +Let `E(t)` be "a new DM with target `t` is created encrypted" and `B(t)` be +"`t` is a positively identified bot": + +``` +B(t) := t = resolved BotFather MXID (only when resolution succeeds) + ∨ t ∈ known_bot_user_ids + ∨ t ∈ { room_bindings[*].bot_user_id } + ∨ t ∈ AgentRegistry + +E(t) ⇔ ¬B(t) +``` + +1. Ordinary users always get an encrypted DM (`create_dm()`); only a bot as + defined above gets `create_room()` without encryption. +2. Identification is local and positive. Failure to resolve the BotFather MXID + is **not** evidence; unknown means encrypted (fail-closed). +3. The decision is computed once, in `AppState::should_create_encrypted_dm`, + and every user-facing DM entry point calls it. Entry points never hardcode + `create_encrypted: false`. The only exception is the agent-binding modal, + whose target is a bot by construction. +4. Whenever a DM will be created unencrypted, the user sees a localized notice + before the room is created. + +## Consequences + +Good, because ordinary DMs regain confidentiality by default and bots keep a +supported plaintext path. +Good, because the invariant is executable: it is bound to example unit tests, +`proptest` property tests and a structural guard in +`specs/task-dm-encryption-default.spec.md`, and lifted into +`specs/capabilities/dm-encryption.spec.md`. +Bad, because a bot that Robrix2 has never seen (not configured, discovered, +bound or registered) gets an encrypted DM and may not answer; the user must +register it as an agent first. + +## Alternatives Considered + +- Keep "always plaintext" (status quo before #306): rejected — silent loss of + confidentiality for ordinary users. +- Detect bots server-side (appservice registration, `m.room.member` metadata): + out of scope; not reliably observable from a client. +- Per-DM encryption toggle in the UI: deferred; adds a decision the user + rarely wants to make. + +## Next + +Governed by the capability spec `specs/capabilities/dm-encryption.spec.md` +(promoted from `task-dm-encryption-default` Rules `dm-enc-1` and `dm-enc-2`); +task specs that touch DM creation should declare `satisfies: [ADR-001]`. diff --git a/knowledge/decisions/README.md b/knowledge/decisions/README.md new file mode 100644 index 000000000..fb4eaebf8 --- /dev/null +++ b/knowledge/decisions/README.md @@ -0,0 +1,4 @@ +# Decisions + +MADR-style decision records. One decision per file, `NNNNN-slug.md`. +When NOT to use: routine implementation choices with no real trade-off — leave those in code/comments. diff --git a/knowledge/decisions/adr-template.md b/knowledge/decisions/adr-template.md new file mode 100644 index 000000000..3e3d29a94 --- /dev/null +++ b/knowledge/decisions/adr-template.md @@ -0,0 +1,22 @@ +--- +kind: decision +id: ADR-NNN +title: "Decision Title" +status: Proposed +--- + +## Context + +## Decision + +## Consequences + +Good, because … +Bad, because … + +## Alternatives Considered + +## Next + +Single exit: govern this decision with a requirement document that task +contracts can satisfy. diff --git a/knowledge/guidance/README.md b/knowledge/guidance/README.md new file mode 100644 index 000000000..16aae63cb --- /dev/null +++ b/knowledge/guidance/README.md @@ -0,0 +1,5 @@ +# Guidance + +Agent-facing guidance + skill designation. `liveness: n/a`. Projected into +CLAUDE.md/AGENTS.md via `gen-integrations --with-guidance` and served live via +MCP `guidance.for`. diff --git a/knowledge/guidance/guidance-template.md b/knowledge/guidance/guidance-template.md new file mode 100644 index 000000000..5dea13bbd --- /dev/null +++ b/knowledge/guidance/guidance-template.md @@ -0,0 +1,14 @@ +--- +kind: guidance +id: G-NNN +liveness: n/a +tags: [] +--- + +## Scope + +## Instructions + +## Applies To + +## Skills diff --git a/knowledge/proposals/README.md b/knowledge/proposals/README.md new file mode 100644 index 000000000..2fccb8276 --- /dev/null +++ b/knowledge/proposals/README.md @@ -0,0 +1,4 @@ +# Proposals + +Governance proposals (LEP-style). `liveness: n/a` — never enters the code gate. +Link the decisions a proposal spawns with `## Produces: ADR-NNN`. diff --git a/knowledge/proposals/lep-template.md b/knowledge/proposals/lep-template.md new file mode 100644 index 000000000..e5fed6d3f --- /dev/null +++ b/knowledge/proposals/lep-template.md @@ -0,0 +1,88 @@ +--- +kind: proposal +id: LEP-NNN +title: "Proposal Title" +status: proposed +liveness: n/a +tags: [] +--- + +# Proposal Title + +This template adapts Lore proposal practice for agent-spec; replace all +placeholder prose before submitting. `## Context`, `## Decision`, and +`## Consequences` are the lint-required skeleton; the remaining sections +are Lore-practice depth. + +## Context + +The problem, incident, or external pressure that makes this proposal +necessary, with enough background to evaluate it. + +## Motivation + +The user need or limitation behind the context, and why now. + +## Goals + +- Goal 1. + +## Non-Goals + +- Explicitly out-of-scope item. + +## Decision + +The proposed course of action, at the level of detail needed to judge it. + +## Compatibility + +- CLI and public API: +- File formats: +- Existing specs and KLL artifacts: + +## Migration Plan + +Use `N/A -- no migration required` only when no existing user, artifact, or +workflow needs a transition. + +## Security Considerations + +Describe trust-boundary and abuse implications, or explain why there are none. + +## Privacy Considerations + +Describe paths, metadata, user data, logs, or telemetry implications, or +explain why there are none. + +## Risks and Assumptions + +### Assumptions + +- Assumption. Invalidated if: ... + +### Risks + +- Risk. Mitigation: ... + +## Consequences + +Good, because ... +Bad, because ... + +## Alternatives Considered + +- Alternative and the concrete reason it was rejected. + +## Prior Art + +- Relevant project, paper, tool, or standard. + +## Unresolved Questions + +- Question to resolve before acceptance. + +## Produces + +- On acceptance, mint the produced decision or requirement id here + (inline form: `## Produces: ADR-NNN`). diff --git a/knowledge/requirements/README.md b/knowledge/requirements/README.md new file mode 100644 index 000000000..9ed72a6ae --- /dev/null +++ b/knowledge/requirements/README.md @@ -0,0 +1,13 @@ +# Requirements + +EARS/29148-style requirement records. Use one artifact per stable requirement or grouping requirement. + +Required shape: +- `title:` is the canonical human-readable title used by graph, work-unit, and spec draft generation. +- `## Problem` explains the user or system problem. +- `## Requirements` is the normative source, with one `[REQ-NNN] ... MUST/SHOULD/MAY ...` clause per line. +- `## Scenarios` supplies the work-unit and draft-spec BDD source. +- `## Dependencies` declares ordering edges to other requirement ids. +- `## Open Questions` blocks executable work-unit generation when it contains real questions. + +Specs link back via `satisfies:`. diff --git a/knowledge/requirements/req-template.md b/knowledge/requirements/req-template.md new file mode 100644 index 000000000..8ac72ddc1 --- /dev/null +++ b/knowledge/requirements/req-template.md @@ -0,0 +1,39 @@ +--- +kind: requirement +id: REQ-NNN +title: "Requirement Title" +liveness: auto +tags: [] +--- + +## Problem + +Describe the user or system problem this requirement solves. + +## Requirements + +[REQ-NNN] The system MUST produce an observable response. + +## Scenarios + +Scenario: Main behavior + Given a concrete starting state + When a concrete action occurs + Then a concrete observable outcome occurs + +## Dependencies + +None. + +## Source Trace + +- issue:#NNN + +## Open Questions + +None. + +## Next + +Single exit: compile this requirement into a task contract with +`agent-spec requirements draft-specs`. diff --git a/knowledge/standards/canon/artifact-types.md b/knowledge/standards/canon/artifact-types.md new file mode 100644 index 000000000..a63e93b92 --- /dev/null +++ b/knowledge/standards/canon/artifact-types.md @@ -0,0 +1,9 @@ +# Artifact types (canon) + +- decision — `## Context · ## Decision · ## Consequences`; recommended `## Alternatives Considered`; `supersedes:`. +- requirement — `## Problem · ## Requirements` ([REQ-NNN] MUST/SHOULD/MAY); BCP-14/29148/EARS quality lint. +- guidance — `## Scope · ## Instructions`; `## Applies To · ## Skills`; `liveness: n/a`. +- proposal — MADR shape; `liveness: n/a`; `## Produces:` edge to decisions/requirements. +- context — free-form, untyped, unlinted (escape hatch). + +This canon documents the schema the lint enforces. It is exempt from artifact lint. diff --git a/scripts/spec-guard.sh b/scripts/spec-guard.sh new file mode 100755 index 000000000..6e000794e --- /dev/null +++ b/scripts/spec-guard.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Spec regression gate for robrix2 (pre-commit + CI). +# +# What it enforces (and what it deliberately does not): +# 1. `agent-spec lint --min-score 0.7` on specs changed in this change set. +# Legacy specs are not re-linted, so old lint debt does not block PRs. +# 2. Structural guards from specs/structure-guards.txt (`agent-spec check-structure`). +# 3. Capability specs (specs/capabilities/*.spec.md) must fully pass +# (`agent-spec lifecycle`, no skips), then every ADR they satisfy must be +# `honored` (`agent-spec trace --gate`). +# 4. Task specs. Robrix2 task specs bind UI / homeserver scenarios to +# `manual_test_*` selectors, which agent-spec reports as `skip`, and the +# boundary layer applies the whole change set to every spec, so a naive +# `agent-spec guard` fails on every PR. Instead: +# - specs changed in this change set are the active contracts: run +# `lifecycle` WITH the change set (boundaries + tests), fail on failed>0; +# - all other specs are regression checks: run `verify` WITHOUT a change +# set (tests only), fail on failed>0. Skips are tolerated. +# +# Usage: +# scripts/spec-guard.sh [--change-scope staged|worktree] [--base ] [--fast] +# --change-scope local mode: git staged (default) or worktree changes +# --base CI mode: change set = `git diff --name-only ...HEAD` +# --fast steps 1-2 only (cheap pre-commit path) +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +SCOPE="staged"; BASE=""; FAST=0 +while [ $# -gt 0 ]; do + case "$1" in + --change-scope) SCOPE="$2"; shift 2;; + --base) BASE="$2"; shift 2;; + --fast) FAST=1; shift;; + *) echo "unknown arg: $1" >&2; exit 64;; + esac +done + +if ! command -v agent-spec >/dev/null 2>&1; then + echo "spec-guard: agent-spec CLI not found (cargo install agent-spec --locked)" >&2 + exit 127 +fi + +# ---- change set --------------------------------------------------------------- +if [ -n "$BASE" ]; then + mapfile -t CHANGED < <(git diff --name-only "$BASE...HEAD" --diff-filter=ACMR) +elif [ "$SCOPE" = "worktree" ]; then + mapfile -t CHANGED < <({ git diff --name-only --diff-filter=ACMR; git diff --name-only --cached --diff-filter=ACMR; git ls-files --others --exclude-standard; } | sort -u) +else + mapfile -t CHANGED < <(git diff --name-only --cached --diff-filter=ACMR) +fi +CHANGED_SPECS=() +for f in "${CHANGED[@]:-}"; do + case "$f" in specs/*.spec.md|specs/*/*.spec.md) [ -f "$f" ] && CHANGED_SPECS+=("$f");; esac +done + +fail=0 +say() { printf '\n== %s\n' "$*"; } + +# ---- 1. lint changed specs ----------------------------------------------------- +say "1/4 lint changed specs (${#CHANGED_SPECS[@]})" +for s in "${CHANGED_SPECS[@]:-}"; do + [ -n "$s" ] || continue + if ! agent-spec lint "$s" --min-score 0.7; then echo "spec-guard: lint failed: $s"; fail=1; fi +done + +# ---- 2. structural guards ------------------------------------------------------ +say "2/4 structural guards (specs/structure-guards.txt)" +if [ -f specs/structure-guards.txt ]; then + while IFS='|' read -r forbid glob; do + forbid="$(echo "$forbid" | sed 's/^ *//; s/ *$//')"; glob="$(echo "$glob" | sed 's/^ *//; s/ *$//')" + [ -z "$forbid" ] && continue; case "$forbid" in \#*) continue;; esac + if ! agent-spec check-structure --code . --forbid "$forbid" --in "$glob"; then fail=1; fi + done < specs/structure-guards.txt +fi + +if [ "$FAST" = "1" ]; then + [ "$fail" = "0" ] && echo "spec-guard (fast): OK" || echo "spec-guard (fast): FAILED" + exit "$fail" +fi + +# ---- 3. capability specs + ADR liveness --------------------------------------- +say "3/4 capability specs must fully pass; satisfied ADRs must be honored" +mkdir -p .agent-spec/runs +for cap in specs/capabilities/*.spec.md; do + [ -f "$cap" ] || continue + if ! agent-spec lifecycle "$cap" --code . --run-log-dir .agent-spec/runs >/dev/null 2>&1; then + echo "spec-guard: capability spec not fully passing: $cap" + agent-spec lifecycle "$cap" --code . --run-log-dir .agent-spec/runs 2>&1 | grep -E '"verdict": "(fail|skip)"' -B6 | grep -E 'scenario_name|reason' | head -20 || true + fail=1 + else + echo "ok: $cap" + fi + # ADR ids from `satisfies:` frontmatter + for adr in $(sed -n 's/^satisfies:[[:space:]]*\[\(.*\)\]/\1/p' "$cap" | tr ',' ' '); do + if ! agent-spec trace "$adr" --gate; then echo "spec-guard: liveness gate failed for $adr"; fail=1; fi + done +done + +# ---- 4. task specs --------------------------------------------------------------- +# 4a. Changed specs are the active contracts of this change: verify them WITH the +# change set (boundaries + bound tests). Manual `skip`s are tolerated; any +# `failed > 0` (test or boundary violation) fails the gate. +# 4b. Every other spec is a regression check: verify WITHOUT a change set (no +# boundary layer, since foreign files would trivially violate them) and fail +# only on `failed > 0`. +say "4/4 task specs: changed = contract check (with change set), others = regression (tests only)" +CHANGE_ARGS=() +for f in "${CHANGED[@]:-}"; do [ -n "$f" ] && [ -e "$f" ] && CHANGE_ARGS+=(--change "$f"); done +summary_failed() { grep -Eo '"failed": [0-9]+' | head -1 | grep -Eo '[0-9]+'; } +is_changed_spec() { local x; for x in "${CHANGED_SPECS[@]:-}"; do [ "$x" = "$1" ] && return 0; done; return 1; } +for spec in specs/*.spec.md; do + case "$spec" in specs/project.spec.md) continue;; esac + if is_changed_spec "$spec"; then + out="$(agent-spec lifecycle "$spec" --code . --run-log-dir .agent-spec/runs "${CHANGE_ARGS[@]}" --format json 2>&1 || true)" + n="$(echo "$out" | summary_failed || echo "?")" + if [ "$n" != "0" ]; then + echo "FAIL (contract): $spec — failed=$n" + echo "$out" | grep -E '"reason": "not covered|"verdict": "fail"' -B3 | grep -E 'reason|step_text|scenario_name' | head -12 + fail=1 + else + echo "ok (contract): $spec" + fi + else + out="$(agent-spec verify "$spec" --code . --format json 2>&1 || true)" + n="$(echo "$out" | summary_failed || echo "?")" + if [ "$n" != "0" ]; then + echo "FAIL (regression): $spec — failed=$n" + echo "$out" | grep -E '"verdict": "fail"' -B8 | grep -E 'scenario_name|reason' | head -8 + fail=1 + fi + fi +done + +if [ "$fail" = "0" ]; then echo; echo "spec-guard: OK"; else echo; echo "spec-guard: FAILED"; fi +exit "$fail" diff --git a/specs/capabilities/dm-encryption.spec.md b/specs/capabilities/dm-encryption.spec.md new file mode 100644 index 000000000..f45bfca1b --- /dev/null +++ b/specs/capabilities/dm-encryption.spec.md @@ -0,0 +1,73 @@ +spec: capability +name: "dm-encryption" +tags: [capability, security, dm, e2ee] +satisfies: [ADR-001] +--- + +## Intent + +Long-lived behavioral truth for direct-message encryption in Robrix2, lifted +from `task-dm-encryption-default` (issue #306) and governed by +`knowledge/decisions/00001-dm-encryption-default.md` (ADR-001). + + + +## Acceptance Criteria + +### Rule: dm-enc-1 — Encrypt iff the target is not an identified bot + +Scenario: Ordinary user DM is encrypted + Tags: critical + Test: should_create_encrypted_dm_encrypts_ordinary_user + Given default bot settings with BotFather resolved to `@bot:example.org` + When `should_create_encrypted_dm` is evaluated for `@alice:example.org` + Then it returns `true` + +Scenario: Configured BotFather DM is unencrypted + Test: should_create_encrypted_dm_plaintext_for_configured_botfather + Given bot settings whose BotFather resolves to `@bot:example.org` + When `should_create_encrypted_dm` is evaluated for `@bot:example.org` + Then it returns `false` + +Scenario: Unrelated user stays encrypted even when bots are configured + Tags: critical + Test: should_create_encrypted_dm_encrypts_unrelated_user_with_bots_configured + Given bot settings with a known bot, a room-bound bot, and a resolved BotFather + When `should_create_encrypted_dm` is evaluated for a user matching none of them + Then it returns `true` + +Scenario: Property — encryption decision equals negated bot identification for all inputs + Tags: critical + Test: prop_should_create_encrypted_dm_iff_not_identified_bot + Given generated bot settings, target user and optional current user + When `should_create_encrypted_dm` and an independent evidence oracle are both evaluated + Then `should_create_encrypted_dm` equals the negation of the oracle for every generated case + +Scenario: Property — AppState decision equals negated (agent ∨ bot) for all inputs + Test: prop_app_state_encrypts_unless_agent_or_bot + Given generated bot settings and 0-3 registered agents + When `AppState::should_create_encrypted_dm` is evaluated + Then it equals `!(target ∈ agents ∨ oracle_is_bot(target))` for every generated case + +### Rule: dm-enc-2 — Plaintext requires positive evidence; unknown is safe + +Scenario: Unresolvable BotFather does not disable encryption + Test: should_create_encrypted_dm_encrypts_when_botfather_unresolvable + Given bot settings with a localpart-only BotFather and no current user id + When `should_create_encrypted_dm` is evaluated for `@alice:example.org` + Then it returns `true` + +Scenario: Property — every plaintext decision is backed by local evidence + Tags: critical + Test: prop_plaintext_requires_positive_evidence + Given generated bot settings, target user and optional current user + When `should_create_encrypted_dm` returns `false` + Then the target equals the successfully resolved BotFather, or is a known bot, or is a room-bound bot + And when BotFather resolution fails and the target is neither known nor bound the decision is `true` diff --git a/specs/structure-guards.txt b/specs/structure-guards.txt new file mode 100644 index 000000000..916865d74 --- /dev/null +++ b/specs/structure-guards.txt @@ -0,0 +1,6 @@ +# Mechanical layering / single-decision-point guards, one per line: +# | +# Enforced by scripts/spec-guard.sh via `agent-spec check-structure`. +# ADR-001 / dm-enc-4: user-facing DM entry points never hardcode a plaintext DM. +create_encrypted: false | src/{home,profile}/** +create_encrypted: false | src/settings/agent_settings.rs From b78e23018c45e3872c5e3ddceefa0c681914b0c9 Mon Sep 17 00:00:00 2001 From: AlexZhang Date: Tue, 18 Aug 2026 18:10:17 +0800 Subject: [PATCH 2/2] chore(specs): spec-guard: do not hold project.spec.md to the task lint bar Co-Authored-By: Claude Fable 5 --- scripts/spec-guard.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/spec-guard.sh b/scripts/spec-guard.sh index 6e000794e..6cb4d9041 100755 --- a/scripts/spec-guard.sh +++ b/scripts/spec-guard.sh @@ -51,7 +51,10 @@ else fi CHANGED_SPECS=() for f in "${CHANGED[@]:-}"; do - case "$f" in specs/*.spec.md|specs/*/*.spec.md) [ -f "$f" ] && CHANGED_SPECS+=("$f");; esac + case "$f" in + specs/project.spec.md) ;; # project-level spec has no scenarios; not held to the task lint bar + specs/*.spec.md|specs/*/*.spec.md) [ -f "$f" ] && CHANGED_SPECS+=("$f");; + esac done fail=0