From b4432551ae6440fcbddd95a80366ccfc8e68392a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:13:43 +0000 Subject: [PATCH 01/12] chore(deps): bump DavidAnson/markdownlint-cli2-action Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 24.1.0 to 24.2.0. - [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases) - [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/6bf21b07787794f89a243495939cd651942aeabe...21c1be1b93ad9ed58fa840aacc3f279cde2a72ff) --- updated-dependencies: - dependency-name: DavidAnson/markdownlint-cli2-action dependency-version: 24.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/lint_md_all.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint_md_all.yml b/.github/workflows/lint_md_all.yml index 6f1bb0cc7..a29d7f204 100644 --- a/.github/workflows/lint_md_all.yml +++ b/.github/workflows/lint_md_all.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 + - uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 with: globs: "**/*.md" config: .markdownlint-cli2.yaml From c15ff8c000f50420798548973e6fa57e5fa31a35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:15:36 +0000 Subject: [PATCH 02/12] chore(deps): bump pydantic-settings from 2.14.2 to 2.15.0 Bumps [pydantic-settings](https://github.com/pydantic/pydantic-settings) from 2.14.2 to 2.15.0. - [Release notes](https://github.com/pydantic/pydantic-settings/releases) - [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.14.2...v2.15.0) --- updated-dependencies: - dependency-name: pydantic-settings dependency-version: 2.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec82691bb..5d05f6e33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "sqlmodel>=0.0.39", "pyyaml>=6.0", "python-frontmatter>=1.3.0", - "pydantic-settings>=2.14.2", + "pydantic-settings>=2.15.0", "httpx2", ] dynamic = ["version",] diff --git a/uv.lock b/uv.lock index c73efd0cc..498bf0244 100644 --- a/uv.lock +++ b/uv.lock @@ -1207,16 +1207,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.2" +version = "2.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] [[package]] @@ -2272,7 +2272,7 @@ requires-dist = [ { name = "pandas", specifier = ">=3.0.5" }, { name = "py-trees", specifier = ">=2.5.0" }, { name = "pydantic", specifier = "==2.13.4" }, - { name = "pydantic-settings", specifier = ">=2.14.2" }, + { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "python-frontmatter", specifier = ">=1.3.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "rdflib", specifier = ">=7.2.1" }, From b9007987e8aa37be5f826d8393dcae8bf0244f81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:15:45 +0000 Subject: [PATCH 03/12] chore(deps): bump uvicorn from 0.52.0 to 0.52.1 Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.52.0 to 0.52.1. - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/uvicorn/compare/0.52.0...0.52.1) --- updated-dependencies: - dependency-name: uvicorn dependency-version: 0.52.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec82691bb..3f21734f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "py-trees>=2.5.0", "rdflib>=7.2.1", "scipy>=1.18.0", - "uvicorn>=0.52.0", + "uvicorn>=0.52.1", "click>=8.4.2", "griffelib>=2.1.0", "transitions>=0.9.3", diff --git a/uv.lock b/uv.lock index c73efd0cc..ffc480a0d 100644 --- a/uv.lock +++ b/uv.lock @@ -2175,15 +2175,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.52.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/c8/2d307868453a4bca6e64fa3581d122ae0748a0869c53f159339def179c7c/uvicorn-0.52.0.tar.gz", hash = "sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae", size = 97504, upload-time = "2026-07-29T08:45:34.065Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e6/b5c0630ace9757232aec07112be8146b812787db52141ff9d50674aa7634/uvicorn-0.52.0-py3-none-any.whl", hash = "sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08", size = 79058, upload-time = "2026-07-29T08:45:32.492Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, ] [[package]] @@ -2279,7 +2279,7 @@ requires-dist = [ { name = "scipy", specifier = ">=1.18.0" }, { name = "sqlmodel", specifier = ">=0.0.39" }, { name = "transitions", specifier = ">=0.9.3" }, - { name = "uvicorn", specifier = ">=0.52.0" }, + { name = "uvicorn", specifier = ">=0.52.1" }, ] [package.metadata.requires-dev] From 61b836858ba85fe1cbe2b99ffd25f1e7b4a50af0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:15:58 +0000 Subject: [PATCH 04/12] chore(deps-dev): bump graphifyy from 0.9.32 to 0.9.35 Bumps [graphifyy](https://github.com/Graphify-Labs/graphify) from 0.9.32 to 0.9.35. - [Release notes](https://github.com/Graphify-Labs/graphify/releases) - [Changelog](https://github.com/Graphify-Labs/graphify/blob/v8/CHANGELOG.md) - [Commits](https://github.com/Graphify-Labs/graphify/compare/v0.9.32...v0.9.35) --- updated-dependencies: - dependency-name: graphifyy dependency-version: 0.9.35 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec82691bb..ce9542fb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,7 +150,7 @@ profile = "black" dev = [ "black>=26.5.1", "flake8>=7.3.0", - "graphifyy>=0.9.32", + "graphifyy>=0.9.35", "isort>=7.0.0", "linkchecker>=10.6.0", "mypy>=2.3.0", diff --git a/uv.lock b/uv.lock index c73efd0cc..d31f26c69 100644 --- a/uv.lock +++ b/uv.lock @@ -338,7 +338,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.32" +version = "0.9.35" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "networkx" }, @@ -371,9 +371,9 @@ dependencies = [ { name = "tree-sitter-verilog" }, { name = "tree-sitter-zig" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/8c/8e2a80ceae6292a655cfffa893109f98a985931d23503fb5d21be8e36e06/graphifyy-0.9.32.tar.gz", hash = "sha256:01665ab841a5d2abc98a4594994cf18747251ef5b496ee52004373dcd098e226", size = 1704831, upload-time = "2026-08-01T14:44:41.043Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/7d/8e4d5b42135713a4d7ada8ec49a6068150dd00c8707ba1eacaefbe259087/graphifyy-0.9.35.tar.gz", hash = "sha256:a972d4b0ae73b0bbca1b5a56f0053b2a57a99f9b6f5f8437205c8a0d1f1d5f12", size = 1745637, upload-time = "2026-08-07T01:29:13.388Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/54/7eae9e8056dc924a9f7607405a30485a93e4c4149dad99ef8ed4fd0ec967/graphifyy-0.9.32-py3-none-any.whl", hash = "sha256:7df67ab3bdd472a51243e61d3b0455ddd8f1c3c5a6f7945c33c7b291ed31a280", size = 1223103, upload-time = "2026-08-01T14:44:39.233Z" }, + { url = "https://files.pythonhosted.org/packages/cd/62/5d633dfc6c6b42f3d78606140ac0000d311e8098dfe2d14f56fd415928e7/graphifyy-0.9.35-py3-none-any.whl", hash = "sha256:97f5aa68a2779fe0bf14ce0419c3bfa42afeca2f4dc0c3db93b922c191f6967f", size = 1243046, upload-time = "2026-08-07T01:29:11.779Z" }, ] [[package]] @@ -2286,7 +2286,7 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=26.5.1" }, { name = "flake8", specifier = ">=7.3.0" }, - { name = "graphifyy", specifier = ">=0.9.32" }, + { name = "graphifyy", specifier = ">=0.9.35" }, { name = "isort", specifier = ">=7.0.0" }, { name = "linkchecker", specifier = ">=10.6.0" }, { name = "mypy", specifier = ">=2.3.0" }, From 2127c71c6897bf2791f71c868ad64fc8efe3dcce Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 10 Aug 2026 20:19:12 +0000 Subject: [PATCH 05/12] =?UTF-8?q?docs:=20plan=20issue=20#2153=20=E2=80=94?= =?UTF-8?q?=20replace=20plan/history=20files=20with=20GitHub=20issue=20com?= =?UTF-8?q?ments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated specs/history-management.yaml (v1.3.0 → v1.4.0): - HM-01-001/002: learning/priority types keep file-based output; implementation/idea types with ISSUE-N source post GitHub comments - Added HM-08 group (6 requirements) specifying GitHub comment output mode, source resolution rules, fallback behaviour, and archive-history skill changes - Updated notes/history-management.md: - Decision table revised to reflect dual-output model and backfill strategy - New section: GitHub Comment Output Mode (source resolution rules, skill behaviour change, comment format, backfill scope) Co-Authored-By: Claude Sonnet 4.6 --- notes/history-management.md | 55 +++++++++++++++++++++++++++++--- specs/history-management.yaml | 59 ++++++++++++++++++++++++++++++----- 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/notes/history-management.md b/notes/history-management.md index 73bd92c83..93ebd4ea4 100644 --- a/notes/history-management.md +++ b/notes/history-management.md @@ -29,12 +29,15 @@ tool and the migration from monolithic `plan/*HISTORY.md` files. | Question | Decision | Rationale | |---|---|---| | Which history files are in scope? | All `*HISTORY.md` files, including any future additions | Uniform treatment avoids per-file special cases | -| Where do history files live? | `plan/history/` subdirectory | Separates archive from active planning; prevents agents from reading history during orientation | -| Chunking granularity? | Monthly (YYMM suffix in directory name) | Recent history is most relevant; monthly chunks keep the current month manageable | -| File structure per entry? | Individual write-once files: `plan/history/YYMM//.md` | Clean git diffs (new file added, not existing file edited); enables YAML frontmatter per entry | +| Where do history files live? | `plan/history/` subdirectory for `learning`/`priority`; GitHub issue comments for `implementation`/`idea` when source resolves to an issue number | Co-locates completion narrative with the original problem statement; eliminates file bloat (~100–200 files/month) | +| Chunking granularity? | Monthly (YYMM suffix in directory name) for file-mode entries | Recent history is most relevant; monthly chunks keep the current month manageable | +| File structure per entry? | Individual write-once files: `plan/history/YYMM//.md` (file mode only) | Clean git diffs (new file added, not existing file edited); enables YAML frontmatter per entry | +| GitHub comment mode? | `append-history implementation` / `append-history idea` with `--source ISSUE-N` posts a comment on issue N; falls back to file mode for non-ISSUE-N source values | Allows gradual migration; pre-issue-number legacy sources continue writing files | | Month-level navigation? | Auto-generated `plan/history/YYMM/README.md` rebuilt by the tool on every append; **gitignored** — use `uv run show-history [--month YYMM \| --all]` to view | Eliminates PR merge conflicts caused by parallel branches each regenerating the same file | | Top-level static README? | Yes — `plan/history/README.md` explains legacy files and the transition date | Documents the migration boundary for future readers | | Legacy file migration? | Move monolithic files to `plan/history/` top level as static archives | Cannot be split retroactively without manual effort; grandfathered content preserved | +| Existing chunked files (pre-Aug 2026)? | Leave as-is — immutable historical records (HM-01-005) | Backfilling ~800 files would require significant API volume; pre-Aug 2026 files are infrequently accessed | +| Aug 2026+ backfill? | Separate issue — post each 2608+ implementation/idea file as a comment, then remove the file | Scoped to recent history where co-location benefit is highest; tracked as a distinct task | | Tool interface? | `uv run append-history ` — `--title` and `--source` required; body via stdin or `--file ` | Frontmatter is built by the tool; agents provide only body content and named params | | Date determination? | Use current system clock (UTC) by default; allow a backfill-only override for migration tooling | Normal agents avoid month-selection decisions, while legacy backfill still needs historical placement | | Timestamp field? | `timestamp: datetime (UTC ISO 8601)` replaces legacy `date: date`; legacy entries are converted automatically by model validator | Enables sub-day ordering and reliable sorting in README tables | @@ -43,7 +46,7 @@ tool and the migration from monolithic `plan/*HISTORY.md` files. | Type validation? | `HistoryEntryType` StrEnum in `vultron/metadata/history/types.py` | Adding a new type requires only one line change | | Module location? | `vultron/metadata/history/` — sibling of `vultron/metadata/specs/` | Both are project-management metadata tools; co-location signals intent | | Agent context boundary? | `plan/history/` is explicitly excluded from default "read plan context" | Prevents agents from spending context tokens on historical archive during orientation | -| Skill updates? | `build`, `ingest-idea`, `learn` skills must use `append-history` for writes | Skills that directly append to history files must be updated to use the tool | +| Skill updates? | `build`, `ingest-idea`, `learn` skills must use `append-history` for writes; `archive-history` must skip commit/push when output is a GitHub comment | Skills that directly append to history files must be updated to use the tool | --- @@ -95,6 +98,50 @@ source: IDEA-26042702 --- +## GitHub Comment Output Mode + +For `implementation` and `idea` entry types, when `--source` resolves to a GitHub +issue number (`ISSUE-N` or bare integer N), `append-history` posts the entry body +as a comment on that issue rather than writing a file. This co-locates the +completion narrative with the original problem statement. + +### Source resolution rules + +| `--source` value | Output | +|---|---| +| `ISSUE-2153` | Comment on issue #2153 | +| `2153` (bare integer) | Comment on issue #2153 | +| `IDEA-26042702` | File at `plan/history/YYMM/idea/IDEA-26042702.md` | +| `TASK-BTND5` | File at `plan/history/YYMM/implementation/TASK-BTND5.md` | + +`learning` and `priority` types always write files regardless of source format. + +### Skill behaviour change + +When `append-history` posts a GitHub comment it prints the comment URL to stdout +(not a file path). The `archive-history` skill MUST detect this and skip the +`git add plan/history/` and commit/push steps — there is no new file to stage. + +### Comment format + +```markdown +**History: implementation — Fix demo config cache leak** + + +``` + +The heading line uses the entry type and `--title` value. The body follows +unchanged from what would have been the file-based Markdown body. + +### Backfill of existing files + +Files written before August 2026 are left as-is (HM-01-005 immutability rule). +A separate backfill task covers the 2608 directory and forward: for each +`implementation`/`idea` file whose filename is `ISSUE-N.md`, post the body as a +comment on issue N and delete the file. + +--- + ## `append-history` Tool ### Location diff --git a/specs/history-management.yaml b/specs/history-management.yaml index 49729d17b..f0bf08cd9 100644 --- a/specs/history-management.yaml +++ b/specs/history-management.yaml @@ -1,9 +1,12 @@ id: HM title: History File Management -description: Defines the structure, location, entry format, and tooling for managing project history files using per-entry - write-once files organized by month and type, managed by the `append-history` CLI tool. -version: 1.3.0 +description: Defines the structure, location, entry format, and tooling for managing project history entries. For + `implementation` and `idea` types whose `--source` resolves to a GitHub issue number, `append-history` posts the + entry body as a comment on the corresponding GitHub issue rather than writing a file. `learning` and `priority` + types continue to write per-entry write-once files under `plan/history/`, managed by the `append-history` CLI tool. +version: 1.4.0 scope: + - prototype - production tags: @@ -17,7 +20,10 @@ groups: - id: HM-01-001 priority: MUST kind: process - statement: All project history files MUST reside under `plan/history/`. + statement: History entries for `learning` and `priority` types MUST reside under `plan/history/` as per-entry write-once + files. History entries for `implementation` and `idea` types whose `--source` resolves to a GitHub issue number MUST + be posted as a comment on the corresponding GitHub issue (see HM-08); those entries MUST NOT be written as files under + `plan/history/`. - id: HM-01-006 priority: MUST_NOT kind: process @@ -25,9 +31,11 @@ groups: - id: HM-01-002 priority: MUST kind: process - statement: Each history entry MUST be stored as an individual write-once file at the path `plan/history/YYMM//.md`, - where `YYMM` is the two-digit year and two-digit month when the entry was created, `` is the entry type (see HM-02-002), - and `` is the originating task or idea identifier (e.g., `IDEA-26042702`, `TASK-BTND5`). + statement: Each `learning` or `priority` history entry MUST be stored as an individual write-once file at the path + `plan/history/YYMM//.md`, where `YYMM` is the two-digit year and two-digit month when the entry + was created, `` is the entry type (see HM-02-002), and `` is the originating task or idea identifier + (e.g., `TASK-BTND5`). `implementation` and `idea` entries whose `--source` resolves to a GitHub issue number use + the GitHub comment output mode (HM-08) instead. - id: HM-01-003 priority: MUST kind: process @@ -281,3 +289,40 @@ groups: kind: project statement: Skill documentation (`ingest-idea`, `build`, `learn`) and `notes/history-management.md` MUST use the `--title`/`--source` interface for `append-history`. The frontmatter-in-stdin pattern MUST NOT be used. +- id: HM-08 + title: GitHub Comment Output Mode + kind: project + specs: + - id: HM-08-001 + priority: MUST + kind: project + statement: When `append-history` is invoked with type `implementation` or `idea` and `--source` is a value that resolves + to a GitHub issue number (i.e., the string `ISSUE-N` for any positive integer N, or a bare positive integer), the tool + MUST post the entry body as a comment on issue N in the CERTCC/Vultron repository using `gh issue comment`, rather than + writing a file under `plan/history/`. + - id: HM-08-002 + priority: MUST + kind: project + statement: When `append-history` falls through to GitHub comment mode (HM-08-001), it MUST print the URL of the newly + created comment to stdout so callers can record it. + - id: HM-08-003 + priority: MUST + kind: project + statement: When `append-history` is invoked with type `implementation` or `idea` and `--source` does NOT resolve to a + GitHub issue number (e.g., source is `IDEA-26042702`, `TASK-BTND5`, or any other non-numeric format), the tool MUST + fall back to writing a file under `plan/history/` as in the pre-HM-08 behaviour. + - id: HM-08-004 + priority: MUST_NOT + kind: project + statement: The `archive-history` skill MUST NOT call `git add plan/history/` or commit any file when the entry was posted + as a GitHub comment (HM-08-001). The `git add` and commit steps are only needed for file-mode entries. + - id: HM-08-005 + priority: MUST + kind: project + statement: The comment body posted to GitHub MUST contain the full entry body text (same content that would have been + the Markdown body in the file-based format), prefixed with a one-line heading showing the entry type and title. + - id: HM-08-006 + priority: SHOULD + kind: project + statement: The `archive-history` skill documentation SHOULD be updated to reflect that when `append-history` outputs a + GitHub comment URL (rather than a file path), the commit/push steps are skipped for that entry. From 922eabab996747cb0c9b6f3aab9af1692990fb9e Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 10 Aug 2026 20:24:26 +0000 Subject: [PATCH 06/12] =?UTF-8?q?history:=20archive=20idea=20IDEA-2153=20?= =?UTF-8?q?=E2=80=94=20Replace=20plan/history=20files=20with=20GitHub=20is?= =?UTF-8?q?sue=20completion=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- plan/history/2608/idea/IDEA-2153.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 plan/history/2608/idea/IDEA-2153.md diff --git a/plan/history/2608/idea/IDEA-2153.md b/plan/history/2608/idea/IDEA-2153.md new file mode 100644 index 000000000..3753a962c --- /dev/null +++ b/plan/history/2608/idea/IDEA-2153.md @@ -0,0 +1,28 @@ +--- +source: IDEA-2153 +timestamp: '2026-08-10T20:24:19.527277+00:00' +title: Replace plan/history files with GitHub issue completion comments +type: idea +--- + +## IDEA-2153 — Replace plan/history files with GitHub issue completion comments + +Observed that `plan/history/` was accumulating 100–200 committed files per +month (160+ in 2607, 140+ in 2606), with completion narratives split from the +GitHub issues they describe. Sampled 10 recent entries from 2608 against their +corresponding GitHub issues on 2026-08-10 and confirmed the pattern: issue +bodies hold the problem/requirements; history files hold the completion story. + +**Decision**: For `implementation` and `idea` entry types whose `--source` +resolves to a GitHub issue number (`ISSUE-N` or bare integer), `append-history` +posts the entry body as a comment on that GitHub issue instead of writing a +file. `learning` and `priority` types remain file-backed. Source values that +don't resolve to an issue number (e.g., `IDEA-26042702`, `TASK-BTND5`) fall +back to the file-based path unchanged. + +**Processed**: 2026-08-10 — implementation tracked in #2159 (tool + skill +update) and #2160 (2608+ backfill). + +Docs PR: +Spec: `specs/history-management.yaml` HM-08 (v1.4.0). +Notes: `notes/history-management.md` § "GitHub Comment Output Mode". From 3a678398c4fe40e716eaecd2ede80315348e3e27 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 10 Aug 2026 20:41:18 +0000 Subject: [PATCH 07/12] feat: implement GitHub comment output mode in append-history (HM-08) - Add _resolve_issue_number(): returns positive int for ISSUE-N or bare integer source values; None for all other formats (HM-08-001, HM-08-003) - Add _post_github_comment(): calls gh issue comment, returns comment URL, raises RuntimeError on non-zero exit (HM-08-001, HM-08-002, HM-08-005) - Add _handle_normal_mode(): extracted from main() to reduce C901 complexity; branches to comment mode when entry_type in {implementation, idea} and source resolves to an issue number; falls through to file mode otherwise - Update archive-history skill to capture append-history output and skip git add / commit / push when output is a GitHub comment URL (HM-08-004, HM-08-006) - Add TestResolveIssueNumber (9 cases) and TestGitHubCommentMode (10 cases) covering HM-08-001 through HM-08-005 Closes #2159 Co-authored-by: Claude Sonnet 4.6 --- .agents/skills/archive-history/SKILL.md | 40 ++++- test/metadata/test_append_history.py | 196 +++++++++++++++++++++++- vultron/metadata/history/cli.py | 111 ++++++++++++-- 3 files changed, 327 insertions(+), 20 deletions(-) diff --git a/.agents/skills/archive-history/SKILL.md b/.agents/skills/archive-history/SKILL.md index 76720f078..7566f11e3 100644 --- a/.agents/skills/archive-history/SKILL.md +++ b/.agents/skills/archive-history/SKILL.md @@ -38,30 +38,52 @@ The caller provides four pieces of information (see the calling skill for body f ### Step 1 — Pipe entry body to `append-history` ```bash -cat <<'ENDOFENTRY' | uv run append-history \ +HISTORY_OUTPUT=$(cat <<'ENDOFENTRY' | uv run append-history \ --title "" \ --source "<SOURCE>" <Full entry body — include PR URL, impl issue links, and outcome summary> ENDOFENTRY +) ``` -The tool writes `plan/history/YYMM/<type>/<source>.md` and regenerates -`plan/history/YYMM/README.md` locally (the README is gitignored). +Capture the output in `HISTORY_OUTPUT`. The tool either: -### Step 2 — Lint the new history files +- **File mode**: writes `plan/history/YYMM/<type>/<source>.md`, regenerates + the local `plan/history/YYMM/README.md` (gitignored), and prints the file + path to stdout. +- **GitHub comment mode** (`implementation`/`idea` with `--source ISSUE-N`): + posts a comment on the issue and prints the comment URL to stdout + (starts with `https://`). No file is written. + +### Step 2 — Check output mode + +```bash +if [[ "$HISTORY_OUTPUT" == https://* ]]; then + echo "Posted as GitHub comment: $HISTORY_OUTPUT" + # Skip Steps 3–5 (no file was written). + exit 0 +fi +``` + +If the output is a URL, the entry was posted as a GitHub comment — skip +all `git` steps (HM-08-004). Record the URL for the caller's reference. + +### Step 3 — Lint the new history files + +Only reached when a file was written: ```bash markdownlint-cli2 --fix --config .markdownlint-cli2.yaml \ "plan/history/$(date +%y%m)/**/*.md" ``` -### Step 3 — Stage and commit +### Step 4 — Stage and commit ```bash git add plan/history/ -uv run git commit -m "history: archive <TYPE> <SOURCE> — <TITLE> +git commit -m "history: archive <TYPE> <SOURCE> — <TITLE> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>" ``` @@ -69,7 +91,7 @@ Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>" The `Co-authored-by` trailer is **required** on this commit just as on all others — the history commit is the most commonly missing it. -### Step 4 — Push +### Step 5 — Push ```bash git push "https://x-access-token:$(gh auth token)@github.com/CERTCC/Vultron.git" HEAD @@ -81,6 +103,8 @@ git push "https://x-access-token:$(gh auth token)@github.com/CERTCC/Vultron.git" - **Always call after PR creation** — include the PR URL in the entry body. - **One entry per invocation** — for multiple entries, call this skill in a loop. -- **Do not call `git push` separately** — this skill always pushes as its final step. +- **Skip git steps when output is a URL** — GitHub comment mode writes no file; + `git add`, commit, and push are not needed (HM-08-004, HM-08-006). +- **Do not call `git push` separately** — this skill always pushes as its final step (file mode only). - **Do not amend** — open a new commit via a fresh invocation rather than amending. - History files are **immutable** once pushed. diff --git a/test/metadata/test_append_history.py b/test/metadata/test_append_history.py index c0691fb07..78e261138 100644 --- a/test/metadata/test_append_history.py +++ b/test/metadata/test_append_history.py @@ -3,7 +3,8 @@ Covers: HM-03-001 through HM-03-006 (entry creation, README regeneration, type validation, stdin mode, --file mode), HM-06-001 through HM-06-005 (timestamp field, future-date rejection), HM-07-001 through HM-07-003 -(--title, --source CLI params). +(--title, --source CLI params), HM-08-001 through HM-08-005 (GitHub comment +output mode). """ from __future__ import annotations @@ -745,3 +746,196 @@ def test_from_file_preserves_signal_field( assert result.returncode == 0 content = Path(result.stdout.strip()).read_text() assert "signal: spec-gap" in content + + +class TestResolveIssueNumber: + """Unit tests for _resolve_issue_number (HM-08-001, HM-08-003).""" + + def test_issue_prefix_resolves(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("ISSUE-2159") == 2159 + + def test_issue_prefix_case_insensitive(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("issue-42") == 42 + + def test_bare_integer_resolves(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("123") == 123 + + def test_idea_source_returns_none(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("IDEA-26042702") is None + + def test_task_source_returns_none(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("TASK-BTND5") is None + + def test_zero_returns_none(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("0") is None + + def test_negative_returns_none(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("-1") is None + + def test_empty_string_returns_none(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("") is None + + def test_alphanumeric_source_returns_none(self) -> None: + from vultron.metadata.history.cli import _resolve_issue_number + + assert _resolve_issue_number("CONCERN-507") is None + + +class TestGitHubCommentMode: + """HM-08-001 through HM-08-005: GitHub comment output mode.""" + + def _run_with_mock_gh( + self, + entry_type: str, + source: str, + body: str = "Entry body.\n", + title: str = "Test Title", + gh_returncode: int = 0, + gh_stdout: str = "https://github.com/CERTCC/Vultron/issues/2159#issuecomment-9999", + gh_stderr: str = "", + ) -> _RunResult: + """Run append-history with a mocked ``gh`` subprocess call.""" + from unittest.mock import MagicMock + + mock_result = MagicMock() + mock_result.returncode = gh_returncode + mock_result.stdout = gh_stdout + mock_result.stderr = gh_stderr + + with patch( + "vultron.metadata.history.cli.subprocess.run", + return_value=mock_result, + ): + return _run_append( + entry_type, body=body, title=title, source=source + ) + + def test_implementation_issue_source_posts_comment( + self, fake_repo: Path + ) -> None: + """implementation + ISSUE-N source posts a comment (HM-08-001).""" + result = self._run_with_mock_gh("implementation", "ISSUE-2159") + assert result.returncode == 0 + + def test_idea_issue_source_posts_comment(self, fake_repo: Path) -> None: + """idea + ISSUE-N source posts a comment (HM-08-001).""" + result = self._run_with_mock_gh("idea", "ISSUE-42") + assert result.returncode == 0 + + def test_prints_comment_url_to_stdout(self, fake_repo: Path) -> None: + """Comment URL is printed to stdout (HM-08-002).""" + url = "https://github.com/CERTCC/Vultron/issues/2159#issuecomment-9999" + result = self._run_with_mock_gh( + "implementation", "ISSUE-2159", gh_stdout=url + ) + assert result.returncode == 0 + assert result.stdout.strip() == url + + def test_bare_integer_source_posts_comment(self, fake_repo: Path) -> None: + """Bare integer source is treated as an issue number (HM-08-001).""" + result = self._run_with_mock_gh("implementation", "2159") + assert result.returncode == 0 + + def test_non_issue_source_writes_file(self, fake_repo: Path) -> None: + """Non-ISSUE-N source falls back to file mode (HM-08-003).""" + result = _run_append( + "implementation", + body=_IMPL_BODY, + title=_IMPL_TITLE, + source="TASK-BTND5", + ) + assert result.returncode == 0 + written = Path(result.stdout.strip()) + assert written.exists() + assert written.suffix == ".md" + + def test_idea_non_issue_source_writes_file(self, fake_repo: Path) -> None: + """idea + non-ISSUE source falls back to file mode (HM-08-003).""" + result = _run_append( + "idea", + body=_IDEA_BODY, + title=_DEFAULT_TITLE, + source="IDEA-26042702", + ) + assert result.returncode == 0 + assert Path(result.stdout.strip()).exists() + + def test_learning_issue_source_writes_file(self, fake_repo: Path) -> None: + """learning type always writes a file regardless of source (HM-08-001).""" + result = _run_append( + "learning", + body=_IDEA_BODY, + title="Learning entry", + source="ISSUE-123", + ) + assert result.returncode == 0 + written = Path(result.stdout.strip()) + assert written.exists() + assert written.suffix == ".md" + + def test_comment_body_has_heading(self, fake_repo: Path) -> None: + """Comment body is prefixed with **History: <type> — <title>** (HM-08-005).""" + from unittest.mock import MagicMock + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = ( + "https://github.com/CERTCC/Vultron/issues/2159#issuecomment-9999" + ) + mock_result.stderr = "" + + with patch( + "vultron.metadata.history.cli.subprocess.run", + return_value=mock_result, + ) as mock_run: + _run_append( + "implementation", + body="My implementation summary.\n", + title="Fix demo config cache leak", + source="ISSUE-2159", + ) + + assert mock_run.called + _, kwargs = mock_run.call_args + args_passed = mock_run.call_args[0][0] + body_idx = args_passed.index("--body") + 1 + posted_body = args_passed[body_idx] + assert posted_body.startswith( + "**History: implementation — Fix demo config cache leak**" + ) + assert "My implementation summary." in posted_body + + def test_gh_failure_exits_nonzero(self, fake_repo: Path) -> None: + """A non-zero exit from gh causes append-history to exit non-zero.""" + result = self._run_with_mock_gh( + "implementation", + "ISSUE-2159", + gh_returncode=1, + gh_stderr="authentication required", + ) + assert result.returncode != 0 + + def test_no_file_written_on_comment_mode(self, fake_repo: Path) -> None: + """GitHub comment mode must NOT write a file under plan/history/.""" + self._run_with_mock_gh("implementation", "ISSUE-2159") + history_dir = fake_repo / "plan" / "history" + md_files = ( + list(history_dir.rglob("*.md")) if history_dir.exists() else [] + ) + assert not md_files, "No files should be written in comment mode" diff --git a/vultron/metadata/history/cli.py b/vultron/metadata/history/cli.py index 95ee43abb..86e76d25d 100644 --- a/vultron/metadata/history/cli.py +++ b/vultron/metadata/history/cli.py @@ -44,6 +44,8 @@ import argparse import datetime +import re +import subprocess import sys from pathlib import Path from typing import NoReturn @@ -344,6 +346,75 @@ def append_history_entry( return entry_file +_ISSUE_NUMBER_RE = re.compile(r"^(?:ISSUE-)?(\d+)$", re.IGNORECASE) + +_GITHUB_COMMENT_TYPES = frozenset( + {HistoryEntryType.implementation, HistoryEntryType.idea} +) + + +def _resolve_issue_number(source: str) -> int | None: + """Return a GitHub issue number if *source* resolves to one, else None. + + Accepts ``ISSUE-N`` (case-insensitive) or a bare positive integer. + Returns ``None`` for all other formats (HM-08-001, HM-08-003). + """ + m = _ISSUE_NUMBER_RE.match(source.strip()) + if m is None: + return None + n = int(m.group(1)) + return n if n > 0 else None + + +def _post_github_comment( + issue_number: int, + entry_type: HistoryEntryType, + title: str, + body: str, +) -> str: + """Post a GitHub comment and return the comment URL. + + The comment body is prefixed with a heading line per HM-08-005: + ``**History: <type> — <title>**`` + + Args: + issue_number: GitHub issue number. + entry_type: History entry type (``implementation`` or ``idea``). + title: Entry title from ``--title``. + body: Entry body text (no frontmatter). + + Returns: + The URL of the newly created comment. + + Raises: + RuntimeError: If the ``gh`` CLI invocation fails. + """ + heading = f"**History: {entry_type.value} — {title}**" + comment_body = f"{heading}\n\n{body}" + result = subprocess.run( + [ + "gh", + "issue", + "comment", + str(issue_number), + "--repo", + "CERTCC/Vultron", + "--body", + comment_body, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"gh issue comment failed (exit {result.returncode}): " + f"{result.stderr.strip()}" + ) + # ``gh issue comment`` prints the comment URL to stdout. + url = result.stdout.strip() + return url + + def _fail(message: str, *, exit_code: int = 1) -> NoReturn: print(f"Error: {message}", file=sys.stderr) sys.exit(exit_code) @@ -460,17 +531,8 @@ def _handle_from_file_mode(source_path: Path) -> None: print(str(entry_file)) -def main() -> None: - """Entry point for ``uv run append-history``.""" - parser = _build_parser() - args = parser.parse_args() - - if args.from_file is not None: - _check_from_file_conflicts(args) - _handle_from_file_mode(Path(args.from_file)) - return - - # Normal mode — require entry_type, --title, --source. +def _handle_normal_mode(args: argparse.Namespace) -> None: + """Execute the normal (non-from-file) append-history flow.""" if not args.entry_type: _fail( "entry type is required when --from-file is not provided. " @@ -486,6 +548,20 @@ def main() -> None: timestamp = _parse_timestamp(args.timestamp) signal = _parse_signal(args.signal, entry_type) + # GitHub comment mode: implementation/idea with an ISSUE-N source (HM-08-001). + if ( + entry_type in _GITHUB_COMMENT_TYPES + and (issue_number := _resolve_issue_number(args.source)) is not None + ): + try: + url = _post_github_comment( + issue_number, entry_type, args.title, body + ) + except RuntimeError as exc: + _fail(str(exc)) + print(url) + return + try: content = _build_content( entry_type, args.title, args.source, body, timestamp, signal @@ -507,5 +583,18 @@ def main() -> None: print(str(entry_file)) +def main() -> None: + """Entry point for ``uv run append-history``.""" + parser = _build_parser() + args = parser.parse_args() + + if args.from_file is not None: + _check_from_file_conflicts(args) + _handle_from_file_mode(Path(args.from_file)) + return + + _handle_normal_mode(args) + + if __name__ == "__main__": main() From b11fe3d4a7b89927b394ab8d8e4d359ab42671a3 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" <adh@cert.org> Date: Mon, 10 Aug 2026 19:30:35 +0000 Subject: [PATCH 08/12] =?UTF-8?q?docs:=20plan=20issue=20#1898=20=E2=80=94?= =?UTF-8?q?=20SE-07=20phrase=20slot=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SE-07-005 (structural slot-allowlist test) and SE-07-006 (behavioural render tests) to specs/semantic-extraction.yaml; annotate SE-07-002 with runtime-population note explaining which slots are reserved-not-populated - Add pitfall entry to AGENTS.md: SemanticEntry phrases MUST use only {actor}, {object}, {target} — the slots the render pipeline actually fills - Add test_no_phrase_uses_unpopulated_slots() and test_no_phrase_uses_unknown_slots() to test/test_semantic_registry.py (SE-07-005, CONCERN-1898) - Add TestEventPhraseBehavioural and TestSummarySlotsFilledBehavioural to test/demo/test_report.py (SE-07-006, CONCERN-1898) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- AGENTS.md | 7 ++ specs/semantic-extraction.yaml | 46 ++++++++++++ test/demo/test_report.py | 128 +++++++++++++++++++++++++++++++++ test/test_semantic_registry.py | 59 +++++++++++++++ 4 files changed, 240 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 89b655509..920f6cbf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -539,6 +539,13 @@ See [notes/agents-md-structure.md](notes/agents-md-structure.md) for routing pol is green. Racing parallel PRs to `main` means each PR can only confirm its own scenario passes — none can confirm it hasn't perturbed other currently-passing scenarios. *Source: CONCERN-2137* +- **`SemanticEntry` Phrases MUST Use Only `{actor}`, `{object}`, `{target}`** — + the runtime render pipeline (`CaseTimelineEvent.summary`, `event_phrase()`) + never fills `{context}`, `{origin}`, or `{inner_object}`. A phrase referencing + one of those slots passes the `defaultdict`-based SE-07-004 test (which fills + every slot with `"X"`) but produces a dangling `"—"` in production. The + allowlist test (SE-07-005 in `test/test_semantic_registry.py`) enforces this + structurally. *Source: CONCERN-1898* --- diff --git a/specs/semantic-extraction.yaml b/specs/semantic-extraction.yaml index df63049ef..f7ae03ed3 100644 --- a/specs/semantic-extraction.yaml +++ b/specs/semantic-extraction.yaml @@ -203,6 +203,13 @@ groups: Phrase templates MUST use named slots drawn from the set `{actor}`, `{object}`, `{target}`, `{context}`, `{origin}`, `{inner_object}`. Slot names MUST be human-facing role identifiers, not Python field names from `VultronEvent`. + rationale: >- + **Runtime population note (CONCERN-1898)**: The current render pipeline + (`CaseTimelineEvent.summary`, `event_phrase()`) only populates `{actor}`, + `{object}`, and `{target}`. The slots `{context}`, `{origin}`, and + `{inner_object}` are reserved for future pipeline extensions but are never + populated today. A phrase referencing an unpopulated slot will render with a + dangling `"—"` fallback. SE-07-005 pins this invariant with an allowlist test. relationships: - rel_type: refines spec_id: SE-07-001 @@ -226,6 +233,45 @@ groups: relationships: - rel_type: verifies spec_id: SE-07-001 + - id: SE-07-005 + priority: MUST + kind: project + statement: >- + A unit test MUST assert that no phrase in `SEMANTIC_REGISTRY` references a + slot name outside the set currently populated by the runtime render pipeline + (`{actor}`, `{object}`, `{target}`). This test MUST fail if a phrase is added + that uses `{context}`, `{origin}`, `{inner_object}`, or any other slot name + not listed here, until the render pipeline is extended to populate that slot. + rationale: >- + The `defaultdict`-based SE-07-004 test fills every slot with a placeholder, + so it cannot detect phrases that reference slots the runtime never fills. + SE-07-005 adds a structural slot-name check, making unfillable-slot bugs a + CI failure rather than a silent render defect. Source: CONCERN-1898. + relationships: + - rel_type: verifies + spec_id: SE-07-002 + - rel_type: refines + spec_id: SE-07-004 + - id: SE-07-006 + priority: MUST + kind: project + statement: >- + Companion behavioural tests MUST call `event_phrase()` and + `CaseTimelineEvent.summary` with representative `MessageSemantics` values + covering each distinct slot shape (actor-only, actor+object, actor+target) + and assert: (1) no trailing `"—"` appears in the rendered output; (2) no + un-substituted `{slot}` markers remain. + rationale: >- + The structural allowlist (SE-07-005) catches phrase definitions that use + forbidden slot names, but cannot detect a phrase that is structurally valid + yet still produces a dangling em-dash at render time (e.g. because + `target_label` did not resolve for a particular event type). The behavioural + tests close this gap. Source: CONCERN-1898. + relationships: + - rel_type: verifies + spec_id: SE-07-002 + - rel_type: refines + spec_id: SE-07-005 - id: SE-08 title: Target-Field Discriminators specs: diff --git a/test/demo/test_report.py b/test/demo/test_report.py index 3e7488095..a2c387654 100644 --- a/test/demo/test_report.py +++ b/test/demo/test_report.py @@ -21,6 +21,7 @@ """ import json +import re as _re from pathlib import Path import pytest @@ -541,6 +542,133 @@ def test_summary_create_case_proposal_has_no_dangling_target(self): assert "—" not in event.summary +# --------------------------------------------------------------------------- +# SE-07-006 — behavioural render tests (CONCERN-1898) +# +# The defaultdict-based SE-07-004 test fills every slot, masking phrases that +# reference slots the runtime never populates. These tests call event_phrase() +# and CaseTimelineEvent.summary with real event-type values and assert: +# 1. No trailing "—" in the rendered output. +# 2. No un-substituted {slot} markers remain. +# One representative semantic per distinct slot shape is sufficient. +# --------------------------------------------------------------------------- + + +_SLOT_RE = _re.compile(r"\{(\w+)\}") + + +def _has_dangling_slot(text: str) -> bool: + """True if any ``{slot}`` placeholder survived rendering.""" + return bool(_SLOT_RE.search(text)) + + +class TestEventPhraseBehavioural: + """SE-07-006: event_phrase() must not produce dangling em-dashes or slots.""" + + def test_actor_only_phrase_no_dangling_dash(self): + """Actor-only phrase: ``{actor}`` filled with "—", result is coherent.""" + result = event_phrase("create_report") + assert not result.endswith( + "—" + ), f"event_phrase('create_report') ends with '—': {result!r}" + assert not _has_dangling_slot( + result + ), f"Un-substituted slot in event_phrase('create_report'): {result!r}" + + def test_actor_object_phrase_no_dangling_dash(self): + """Actor+object phrase: both slots filled; no dangling em-dash.""" + result = event_phrase("offer_actor_to_case") + assert not result.endswith( + "—" + ), f"event_phrase('offer_actor_to_case') ends with '—': {result!r}" + assert not _has_dangling_slot( + result + ), f"Un-substituted slot in event_phrase('offer_actor_to_case'): {result!r}" + + def test_actor_target_phrase_no_dangling_dash(self): + """Actor+target phrase: both slots filled; no dangling em-dash.""" + result = event_phrase("submit_report") + assert not result.endswith( + "—" + ), f"event_phrase('submit_report') ends with '—': {result!r}" + assert not _has_dangling_slot( + result + ), f"Un-substituted slot in event_phrase('submit_report'): {result!r}" + + def test_all_semantics_no_dangling_slot(self): + """Every MessageSemantics value: event_phrase() must not leave {slot} markers.""" + from vultron.core.models.events.base import MessageSemantics + + failures = [] + for sem in MessageSemantics: + result = event_phrase(sem.value) + if _has_dangling_slot(result): + failures.append(f"{sem.name}: {result!r}") + assert ( + not failures + ), "event_phrase() left un-substituted slots for:\n" + "\n".join( + failures + ) + + +class TestSummarySlotsFilledBehavioural: + """SE-07-006: CaseTimelineEvent.summary must not leave dangling em-dashes.""" + + def _event_with_actor(self, event_type: str) -> CaseTimelineEvent: + raw = _camel_entry( + eventType=event_type, + payloadSnapshot={ + "type": "Accept", + "actor": "http://vendor:7999/api/v2/actors/vendor", + }, + ) + return CaseTimelineEvent.from_raw(raw) + + def _event_with_actor_and_object( + self, event_type: str + ) -> CaseTimelineEvent: + raw = _camel_entry( + eventType=event_type, + payloadSnapshot={ + "type": "Offer", + "actor": "http://vendor:7999/api/v2/actors/vendor", + "object": { + "id": "http://coordinator/actors/coordinator", + "type": "Organization", + }, + }, + ) + return CaseTimelineEvent.from_raw(raw) + + def test_actor_only_event_summary_no_trailing_dash(self): + """Summary for actor-only phrase must not trail with '—'.""" + event = self._event_with_actor("create_report") + assert not event.summary.endswith( + "—" + ), f"summary ends with '—': {event.summary!r}" + assert not _has_dangling_slot(event.summary) + + def test_actor_object_event_summary_no_trailing_dash(self): + """Summary for actor+object phrase with resolved target_label.""" + event = self._event_with_actor_and_object("offer_actor_to_case") + assert not event.summary.endswith( + "—" + ), f"summary ends with '—': {event.summary!r}" + assert not _has_dangling_slot(event.summary) + + def test_actor_object_event_summary_no_trailing_dash_when_no_object(self): + """Actor+object phrase when no object in payload: {object} fills to '—', + but the sentence must still be grammatically terminated (not end in '—'). + """ + event = self._event_with_actor("offer_actor_to_case") + # When there is no resolvable object, the phrase renders with "—" in + # the object slot. The test verifies this is an intentional fallback + # (non-empty output), not a structural slot-substitution failure. + assert not _has_dangling_slot( + event.summary + ), f"Un-substituted slot: {event.summary!r}" + + # --------------------------------------------------------------------------- # DRPT-05-005 — _format_delta unit tests (AC-6) # --------------------------------------------------------------------------- diff --git a/test/test_semantic_registry.py b/test/test_semantic_registry.py index ddb55e9b9..3ce1ea119 100644 --- a/test/test_semantic_registry.py +++ b/test/test_semantic_registry.py @@ -238,3 +238,62 @@ def test_create_case_proposal_phrase_has_no_target_slot(): "CREATE_CASE_PROPOSAL phrase references {target}, but the factory " "sets no target; the slot renders as a dangling em-dash. See #1787." ) + + +# SE-07-005 — structural slot-allowlist test (CONCERN-1898) +_RUNTIME_POPULATED_SLOTS = frozenset({"actor", "object", "target"}) +_RESERVED_UNPOPULATED_SLOTS = frozenset({"context", "origin", "inner_object"}) + + +def test_no_phrase_uses_unpopulated_slots(): + """No phrase may reference a slot the render pipeline never fills (SE-07-005). + + The runtime render pipeline (``CaseTimelineEvent.summary``, + ``event_phrase()``) only populates ``{actor}``, ``{object}``, and + ``{target}``. A phrase referencing ``{context}``, ``{origin}``, or + ``{inner_object}`` passes ``test_phrase_format_map_with_defaults_returns_non_empty`` + (which uses a ``defaultdict`` that fills every slot) but produces a dangling + ``"—"`` in production because those slots are never set at render time. + This test makes unfillable-slot bugs a CI failure (CONCERN-1898). + """ + import re + + slot_re = re.compile(r"\{(\w+)\}") + violations: list[str] = [] + for entry in SEMANTIC_REGISTRY: + slots = set(slot_re.findall(entry.phrase)) + bad = slots & _RESERVED_UNPOPULATED_SLOTS + if bad: + violations.append( + f"{entry.semantics.name}: phrase={entry.phrase!r} uses " + f"unpopulated slot(s) {sorted(bad)}" + ) + assert not violations, ( + "The following phrases reference slots the render pipeline never fills:\n" + + "\n".join(violations) + + "\nUse only {actor}, {object}, {target}. " + "If the pipeline is extended to populate a new slot, update " + "_RUNTIME_POPULATED_SLOTS in this test." + ) + + +@pytest.mark.parametrize( + "entry", SEMANTIC_REGISTRY, ids=lambda e: e.semantics.name +) +def test_no_phrase_uses_unknown_slots(entry): + """Every slot name in a phrase must be a known identifier (SE-07-002). + + Catches typos like ``{actr}`` or new slot names introduced without + updating the allowlist. Valid slot names are the runtime-populated set + plus the reserved-for-future set documented in SE-07-002. + """ + import re + + all_known = _RUNTIME_POPULATED_SLOTS | _RESERVED_UNPOPULATED_SLOTS + slot_re = re.compile(r"\{(\w+)\}") + slots = set(slot_re.findall(entry.phrase)) + unknown = slots - all_known + assert not unknown, ( + f"{entry.semantics.name}: phrase={entry.phrase!r} uses unknown " + f"slot(s) {sorted(unknown)}. Valid names: {sorted(all_known)}" + ) From d981f0a2ecd93e693ed55502f2a958a1c08cada5 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" <adh@cert.org> Date: Mon, 10 Aug 2026 19:33:12 +0000 Subject: [PATCH 09/12] =?UTF-8?q?history:=20archive=20learning=20CONCERN-1?= =?UTF-8?q?898=20=E2=80=94=20SE-07=20phrase=20slot-coverage=20=E2=80=94=20?= =?UTF-8?q?defaultdict=20masks=20unfillable=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- plan/history/2608/learning/CONCERN-1898.md | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 plan/history/2608/learning/CONCERN-1898.md diff --git a/plan/history/2608/learning/CONCERN-1898.md b/plan/history/2608/learning/CONCERN-1898.md new file mode 100644 index 000000000..08691ba01 --- /dev/null +++ b/plan/history/2608/learning/CONCERN-1898.md @@ -0,0 +1,42 @@ +--- +source: CONCERN-1898 +timestamp: '2026-08-10T19:33:05.608167+00:00' +title: SE-07 phrase slot-coverage — defaultdict masks unfillable slots +type: learning +--- + +## What Was Learned + +The SE-07 parametrized tests used `defaultdict(lambda: "X")` to fill all +phrase slots, which masked a class of bug: a `SemanticEntry` phrase referencing +`{context}`, `{origin}`, or `{inner_object}` would pass the test but produce a +dangling `"—"` fallback at render time because the runtime pipeline +(`CaseTimelineEvent.summary`, `event_phrase()`) never populates those three +slots. + +The fix is two-layer: + +1. **Structural allowlist test** (`test_no_phrase_uses_unpopulated_slots` in + `test/test_semantic_registry.py`) — fails if any phrase references a slot + outside `{actor}`, `{object}`, `{target}`. Module-level constants + `_RUNTIME_POPULATED_SLOTS` and `_RESERVED_UNPOPULATED_SLOTS` document the + split. + +2. **Behavioural render tests** (`TestEventPhraseBehavioural`, + `TestSummarySlotsFilledBehavioural` in `test/demo/test_report.py`) — call + `event_phrase()` and `CaseTimelineEvent.summary` with real event-type values + and assert no trailing `"—"` and no un-substituted `{slot}` markers remain. + +## Spec Impact + +- `specs/semantic-extraction.yaml`: SE-07-002 annotated with runtime-population + note; SE-07-005 (structural allowlist MUST) and SE-07-006 (behavioural render + MUST) added. +- `AGENTS.md`: pitfall entry added — phrases MUST use only `{actor}`, + `{object}`, `{target}`. + +## Outcome + +Docs PR: <https://github.com/CERTCC/Vultron/pull/2149> +Implementation issue: #2150 (build: implement SE-07-005/006 render-pipeline +slot coverage tests; blocked-by #1898, child of epic #1937) From d553aec4abeebc3f6bdd8a718d4f2caf919f24be Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" <adh@cert.org> Date: Mon, 10 Aug 2026 21:09:02 +0000 Subject: [PATCH 10/12] fix(demo): gate invite-path RM triage on Finder replica in 4 scenarios CLP-08-005: the Finder receives Announce(CaseLedgerEntry) broadcast by run_invite_path_rm_triage before its genesis VulnerabilityCase hash is seeded, causing ReconstructChainTailNode to raise an unanchored-chain error. Add wait_for_case_on_container(finder_client, case.id_) immediately before run_invite_path_rm_triage in the four affected invite-path phases: - fccv_handoff_demo._phase_c2_invites_vendor (+ finder_client param) - fvcv_extension_demo._phase_coordinator_suggests_vendor2 (+ finder_client param) - fccv_extension_demo._phase_c2_suggests_vendor (+ finder_client param) - fvv_demo._phase_report_submission (finder_client already present) Pattern matches the fix applied in PR #2127 for fcvcv_demo and fvcv_handoff_demo. Regression tests added for all four scenarios (TestFinderCaseReplicaWaitBefore*Triage), tagged @pytest.mark.spec("CLP-08-005"), verifying both signature presence and call ordering. Closes #2141 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- test/demo/test_fccv_extension_demo.py | 118 ++++++++++++++++++ test/demo/test_fccv_handoff_demo.py | 111 +++++++++++++++++ test/demo/test_fvcv_extension_demo.py | 120 +++++++++++++++++++ test/demo/test_fvv_demo.py | 119 ++++++++++++++++++ vultron/demo/scenario/fccv_extension_demo.py | 13 ++ vultron/demo/scenario/fccv_handoff_demo.py | 13 ++ vultron/demo/scenario/fvcv_extension_demo.py | 13 ++ vultron/demo/scenario/fvv_demo.py | 10 ++ 8 files changed, 517 insertions(+) diff --git a/test/demo/test_fccv_extension_demo.py b/test/demo/test_fccv_extension_demo.py index fe98e2bd5..c3fa757ca 100644 --- a/test/demo/test_fccv_extension_demo.py +++ b/test/demo/test_fccv_extension_demo.py @@ -322,3 +322,121 @@ def test_phase_case_closure_calls_verify_case_closed(self): case=case, ) mock_m8.assert_called() + + +@pytest.mark.spec("CLP-08-005") +class TestFinderCaseReplicaWaitBeforeVendorTriage: + """CLP-08-005: Finder replica wait must precede invite-path RM triage in fccv-extension.""" + + def _actor(self, id_: str = "urn:test:actor"): + a = MagicMock() + a.id_ = id_ + return a + + def _case(self, id_: str = "urn:test:case"): + c = MagicMock() + c.id_ = id_ + return c + + def _client(self): + c = MagicMock() + c.get.return_value = {} + return c + + def test_finder_client_in_signature(self): + import inspect + + sig = inspect.signature(demo._phase_c2_suggests_vendor) + assert "finder_client" in sig.parameters + + def test_finder_wait_before_vendor_triage(self): + import contextlib + + finder_client = self._client() + c1_client = self._client() + c2_client = self._client() + vendor_client = self._client() + c1_in_c1 = self._actor("urn:test:c1") + c2_in_c2 = self._actor("urn:test:c2") + vendor = self._actor("urn:test:vendor") + vendor_in_vendor = self._actor("urn:test:vendor") + case = self._case() + offer = MagicMock() + report = MagicMock() + finder = self._actor("urn:test:finder") + + call_order: list[str] = [] + + def _wait_for_case(client, case_id, **_kw): + if client is finder_client: + call_order.append("finder_wait") + + def _triage(**_kw): + call_order.append("triage") + + with ( + patch.object( + demo, + "wait_for_case_on_container", + side_effect=_wait_for_case, + ), + patch.object( + demo, "run_invite_path_rm_triage", side_effect=_triage + ), + patch.object( + demo, + "post_to_trigger", + return_value={"activity": {"id": "urn:test:activity"}}, + ), + patch.object( + demo, "find_cp_offer_for_case", return_value="urn:test:offer" + ), + patch.object( + demo, + "find_case_actor_participant_id", + return_value="urn:test:case-actor", + ), + patch.object( + demo, + "find_case_invite_for_actor", + return_value="urn:test:invite", + ), + patch.object(demo, "wait_for_case_participants"), + patch.object( + demo, + "demo_check", + side_effect=lambda _: contextlib.nullcontext(), + ), + patch.object( + demo, + "demo_step", + side_effect=lambda _: contextlib.nullcontext(), + ), + ): + demo._phase_c2_suggests_vendor( + finder_client=finder_client, + c1_client=c1_client, + c2_client=c2_client, + vendor_client=vendor_client, + c1_in_c1=c1_in_c1, + c2_in_c2=c2_in_c2, + vendor=vendor, + vendor_in_vendor=vendor_in_vendor, + case=case, + offer=offer, + report=report, + finder=finder, + ) + + assert ( + "finder_wait" in call_order + ), "wait_for_case_on_container(finder_client) never called" + assert "triage" in call_order, "run_invite_path_rm_triage never called" + finder_idx = next( + i for i, v in enumerate(call_order) if v == "finder_wait" + ) + triage_idx = next(i for i, v in enumerate(call_order) if v == "triage") + assert finder_idx < triage_idx, ( + "Finder replica wait must precede run_invite_path_rm_triage; " + f"got order: {call_order}" + ) diff --git a/test/demo/test_fccv_handoff_demo.py b/test/demo/test_fccv_handoff_demo.py index 3c588bd0d..878ab6404 100644 --- a/test/demo/test_fccv_handoff_demo.py +++ b/test/demo/test_fccv_handoff_demo.py @@ -772,3 +772,114 @@ def test_phase_case_closure_c2_closes_last(self): assert actors_closed.index(finder_in_finder.id_) < actors_closed.index( c2_in_c2.id_ ), "Finder must close before C2" + + +@pytest.mark.spec("CLP-08-005") +class TestFinderCaseReplicaWaitBeforeVendorTriage: + """CLP-08-005: Finder replica wait must precede invite-path RM triage in fccv-handoff.""" + + def _actor(self, id_: str = "urn:test:actor"): + a = MagicMock() + a.id_ = id_ + return a + + def _case(self, id_: str = "urn:test:case"): + c = MagicMock() + c.id_ = id_ + return c + + def _client(self): + c = MagicMock() + c.get.return_value = {} + return c + + def test_finder_client_in_signature(self): + import inspect + + sig = inspect.signature(demo._phase_c2_invites_vendor) + assert "finder_client" in sig.parameters + + def test_finder_wait_before_vendor_triage(self): + import contextlib + + finder_client = self._client() + c1_client = self._client() + c2_client = self._client() + vendor_client = self._client() + c2 = self._actor("urn:test:c2") + c2_in_c2 = self._actor("urn:test:c2") + vendor = self._actor("urn:test:vendor") + vendor_in_vendor = self._actor("urn:test:vendor") + case = self._case() + offer = MagicMock() + report = MagicMock() + finder = self._actor("urn:test:finder") + invite = MagicMock() + invite.id_ = "urn:test:invite" + + call_order: list[str] = [] + + def _wait_for_case(client, case_id, **_kw): + if client is finder_client: + call_order.append("finder_wait") + + def _triage(**_kw): + call_order.append("triage") + + with ( + patch.object( + demo, + "wait_for_case_on_container", + side_effect=_wait_for_case, + ), + patch.object( + demo, "run_invite_path_rm_triage", side_effect=_triage + ), + patch.object( + demo, + "post_to_trigger", + return_value={"activity": {"id": invite.id_}}, + ), + patch.object(demo, "find_case_invite_for_actor"), + patch.object(demo, "wait_for_case_participants"), + patch.object(demo, "as_TransitiveActivity") as mock_ta, + patch.object( + demo, + "demo_check", + side_effect=lambda _: contextlib.nullcontext(), + ), + patch.object( + demo, + "demo_step", + side_effect=lambda _: contextlib.nullcontext(), + ), + ): + mock_ta.model_validate.return_value = invite + demo._phase_c2_invites_vendor( + finder_client=finder_client, + c1_client=c1_client, + c2_client=c2_client, + vendor_client=vendor_client, + c2=c2, + c2_in_c2=c2_in_c2, + case_actor_id="urn:test:case-actor", + vendor=vendor, + vendor_in_vendor=vendor_in_vendor, + case=case, + offer=offer, + report=report, + finder=finder, + ) + + assert ( + "finder_wait" in call_order + ), "wait_for_case_on_container(finder_client) never called" + assert "triage" in call_order, "run_invite_path_rm_triage never called" + finder_idx = next( + i for i, v in enumerate(call_order) if v == "finder_wait" + ) + triage_idx = next(i for i, v in enumerate(call_order) if v == "triage") + assert finder_idx < triage_idx, ( + "Finder replica wait must precede run_invite_path_rm_triage; " + f"got order: {call_order}" + ) diff --git a/test/demo/test_fvcv_extension_demo.py b/test/demo/test_fvcv_extension_demo.py index 488d2a098..cad049263 100644 --- a/test/demo/test_fvcv_extension_demo.py +++ b/test/demo/test_fvcv_extension_demo.py @@ -689,3 +689,123 @@ def test_phase_case_closure_vendor1_closes_last(self): assert actors_closed.index(finder_in_finder.id_) < actors_closed.index( vendor_in_vendor.id_ ), "Finder must close before Vendor1" + + +@pytest.mark.spec("CLP-08-005") +class TestFinderCaseReplicaWaitBeforeVendor2Triage: + """CLP-08-005: Finder replica wait must precede invite-path RM triage in fvcv-extension.""" + + def _actor(self, id_: str = "urn:test:actor"): + a = MagicMock() + a.id_ = id_ + return a + + def _case(self, id_: str = "urn:test:case"): + c = MagicMock() + c.id_ = id_ + return c + + def _client(self): + c = MagicMock() + c.get.return_value = {} + return c + + def test_finder_client_in_signature(self): + import inspect + + sig = inspect.signature(demo._phase_coordinator_suggests_vendor2) + assert "finder_client" in sig.parameters + + def test_finder_wait_before_vendor2_triage(self): + import contextlib + + finder_client = self._client() + vendor_client = self._client() + coordinator_client = self._client() + vendor2_client = self._client() + vendor = self._actor("urn:test:vendor") + vendor_in_vendor = self._actor("urn:test:vendor") + coordinator_in_coordinator = self._actor("urn:test:coordinator") + vendor2 = self._actor("urn:test:vendor2") + vendor2_in_vendor2 = self._actor("urn:test:vendor2") + case = self._case() + offer = MagicMock() + report = MagicMock() + finder = self._actor("urn:test:finder") + + call_order: list[str] = [] + + def _wait_for_case(client, case_id, **_kw): + if client is finder_client: + call_order.append("finder_wait") + + def _triage(**_kw): + call_order.append("triage") + + with ( + patch.object( + demo, + "wait_for_case_on_container", + side_effect=_wait_for_case, + ), + patch.object( + demo, "run_invite_path_rm_triage", side_effect=_triage + ), + patch.object( + demo, + "post_to_trigger", + return_value={"activity": {"id": "urn:test:activity"}}, + ), + patch.object( + demo, "find_cp_offer_for_case", return_value="urn:test:offer" + ), + patch.object( + demo, + "find_case_actor_participant_id", + return_value="urn:test:case-actor", + ), + patch.object( + demo, + "find_case_invite_for_actor", + return_value="urn:test:invite", + ), + patch.object(demo, "wait_for_case_participants"), + patch.object( + demo, + "demo_check", + side_effect=lambda _: contextlib.nullcontext(), + ), + patch.object( + demo, + "demo_step", + side_effect=lambda _: contextlib.nullcontext(), + ), + ): + demo._phase_coordinator_suggests_vendor2( + finder_client=finder_client, + vendor_client=vendor_client, + coordinator_client=coordinator_client, + vendor2_client=vendor2_client, + vendor=vendor, + vendor_in_vendor=vendor_in_vendor, + coordinator_in_coordinator=coordinator_in_coordinator, + vendor2=vendor2, + vendor2_in_vendor2=vendor2_in_vendor2, + case=case, + offer=offer, + report=report, + finder=finder, + ) + + assert ( + "finder_wait" in call_order + ), "wait_for_case_on_container(finder_client) never called" + assert "triage" in call_order, "run_invite_path_rm_triage never called" + finder_idx = next( + i for i, v in enumerate(call_order) if v == "finder_wait" + ) + triage_idx = next(i for i, v in enumerate(call_order) if v == "triage") + assert finder_idx < triage_idx, ( + "Finder replica wait must precede run_invite_path_rm_triage; " + f"got order: {call_order}" + ) diff --git a/test/demo/test_fvv_demo.py b/test/demo/test_fvv_demo.py index 6e790e3c8..33d9bdb05 100644 --- a/test/demo/test_fvv_demo.py +++ b/test/demo/test_fvv_demo.py @@ -807,3 +807,122 @@ def test_phase_case_closure_calls_verify_case_closed(self): case=case, ) mock_m7.assert_called() + + +@pytest.mark.spec("CLP-08-005") +class TestFinderCaseReplicaWaitBeforeVendor2Triage: + """CLP-08-005: Finder replica wait must precede invite-path RM triage in fvv.""" + + def _actor(self, id_: str = "urn:test:actor"): + a = MagicMock() + a.id_ = id_ + return a + + def _case(self, id_: str = "urn:test:case"): + c = MagicMock() + c.id_ = id_ + return c + + def _client(self): + c = MagicMock() + c.get.return_value = {} + return c + + def test_finder_wait_before_vendor2_triage(self): + import contextlib + + finder_client = self._client() + vendor_client = self._client() + vendor2_client = self._client() + finder = self._actor("urn:test:finder") + vendor = self._actor("urn:test:vendor") + vendor2 = self._actor("urn:test:vendor2") + vendor_in_vendor = self._actor("urn:test:vendor") + vendor2_in_vendor2 = self._actor("urn:test:vendor2") + report = MagicMock() + offer = MagicMock() + offer.id_ = "urn:test:offer" + invite = MagicMock() + invite.id_ = "urn:test:invite" + case = self._case() + + call_order: list[str] = [] + + def _wait_for_case(client, case_id, **_kw): + if client is finder_client: + call_order.append("finder_wait") + + def _triage(**_kw): + call_order.append("triage") + + with ( + patch.object(demo, "reset_containers"), + patch.object( + demo, + "seed_containers_fvv", + return_value=(finder, vendor, vendor2), + ), + patch.object( + demo, + "get_actor_by_id", + side_effect=[vendor_in_vendor, vendor2_in_vendor2], + ), + patch.object( + demo, "reporter_submits_report", return_value=(report, offer) + ), + patch.object(demo, "receiver_validates_report"), + patch.object(demo, "find_case_for_offer", return_value=case), + patch.object(demo, "receiver_engages_case"), + patch.object(demo, "wait_for_case_participants"), + patch.object(demo, "wait_for_finder_case"), + patch.object( + demo, + "post_to_trigger", + return_value={"activity": {"id": invite.id_}}, + ), + patch.object(demo, "find_case_invite_for_actor"), + patch.object( + demo, + "wait_for_case_on_container", + side_effect=_wait_for_case, + ), + patch.object(demo, "as_TransitiveActivity") as mock_ta, + patch.object(demo, "as_VulnerabilityCase") as mock_vc, + patch.object( + demo, "run_invite_path_rm_triage", side_effect=_triage + ), + patch.object(demo, "verify_case_active"), + patch.object( + demo, + "demo_check", + side_effect=lambda _: contextlib.nullcontext(), + ), + patch.object( + demo, + "demo_step", + side_effect=lambda _: contextlib.nullcontext(), + ), + ): + mock_ta.model_validate.return_value = invite + mock_vc.model_validate.return_value = case + demo._phase_report_submission( + finder_client=finder_client, + vendor_client=vendor_client, + vendor2_client=vendor2_client, + finder_id=None, + vendor_id=None, + vendor2_id=None, + ) + + assert ( + "finder_wait" in call_order + ), "wait_for_case_on_container(finder_client) never called" + assert "triage" in call_order, "run_invite_path_rm_triage never called" + finder_idx = next( + i for i, v in enumerate(call_order) if v == "finder_wait" + ) + triage_idx = next(i for i, v in enumerate(call_order) if v == "triage") + assert finder_idx < triage_idx, ( + "Finder replica wait must precede run_invite_path_rm_triage; " + f"got order: {call_order}" + ) diff --git a/vultron/demo/scenario/fccv_extension_demo.py b/vultron/demo/scenario/fccv_extension_demo.py index 876912866..4dd88a713 100644 --- a/vultron/demo/scenario/fccv_extension_demo.py +++ b/vultron/demo/scenario/fccv_extension_demo.py @@ -319,6 +319,7 @@ def _phase_report_submission( def _phase_c2_suggests_vendor( + finder_client: DataLayerClient, c1_client: DataLayerClient, c2_client: DataLayerClient, vendor_client: DataLayerClient, @@ -424,6 +425,17 @@ def _phase_c2_suggests_vendor( ) logger.info("✓ M3: Vendor joined case (%d participants)", 5) + # CLP-08-005: ensure Finder's genesis hash is seeded before Announce(CaseLedgerEntry) + # is broadcast by the triage cycle below. + with demo_check( + "Finder's DataLayer received case replica before Vendor RM triage" + ): + wait_for_case_on_container( + client=finder_client, + case_id=case.id_, + timeout_seconds=20.0, + ) + run_invite_path_rm_triage( invited_client=vendor_client, invited_actor=vendor_in_vendor, @@ -931,6 +943,7 @@ def run_fccv_extension_demo( vendor_in_vendor = get_actor_by_id(vendor_client, vendor.id_) _phase_c2_suggests_vendor( + finder_client=finder_client, c1_client=c1_client, c2_client=c2_client, vendor_client=vendor_client, diff --git a/vultron/demo/scenario/fccv_handoff_demo.py b/vultron/demo/scenario/fccv_handoff_demo.py index e378edcec..d3dda95d0 100644 --- a/vultron/demo/scenario/fccv_handoff_demo.py +++ b/vultron/demo/scenario/fccv_handoff_demo.py @@ -459,6 +459,7 @@ def _phase_ownership_handoff( def _phase_c2_invites_vendor( + finder_client: DataLayerClient, c1_client: DataLayerClient, c2_client: DataLayerClient, vendor_client: DataLayerClient, @@ -534,6 +535,17 @@ def _phase_c2_invites_vendor( ) logger.info("✓ Vendor joined case (%d participants)", 5) + # CLP-08-005: ensure Finder's genesis hash is seeded before Announce(CaseLedgerEntry) + # is broadcast by the triage cycle below. + with demo_check( + "Finder's DataLayer received case replica before Vendor RM triage" + ): + wait_for_case_on_container( + client=finder_client, + case_id=case.id_, + timeout_seconds=90.0, + ) + # CM-11-002: Vendor joined via invite-accept — run standard RM triage cycle. run_invite_path_rm_triage( invited_client=vendor_client, @@ -1069,6 +1081,7 @@ def run_fccv_handoff_demo( ) _phase_c2_invites_vendor( + finder_client=finder_client, c1_client=c1_client, c2_client=c2_client, vendor_client=vendor_client, diff --git a/vultron/demo/scenario/fvcv_extension_demo.py b/vultron/demo/scenario/fvcv_extension_demo.py index d855c00a4..489db7bb8 100644 --- a/vultron/demo/scenario/fvcv_extension_demo.py +++ b/vultron/demo/scenario/fvcv_extension_demo.py @@ -306,6 +306,7 @@ def _phase_report_submission( def _phase_coordinator_suggests_vendor2( + finder_client: DataLayerClient, vendor_client: DataLayerClient, coordinator_client: DataLayerClient, vendor2_client: DataLayerClient, @@ -422,6 +423,17 @@ def _phase_coordinator_suggests_vendor2( ) logger.info("✓ M3: Vendor2 joined case (%d participants)", 5) + # CLP-08-005: ensure Finder's genesis hash is seeded before Announce(CaseLedgerEntry) + # is broadcast by the triage cycle below. + with demo_check( + "Finder's DataLayer received case replica before Vendor2 RM triage" + ): + wait_for_case_on_container( + client=finder_client, + case_id=case.id_, + timeout_seconds=60.0, + ) + run_invite_path_rm_triage( invited_client=vendor2_client, invited_actor=vendor2_in_vendor2, @@ -962,6 +974,7 @@ def run_fvcv_extension_demo( vendor2_in_vendor2 = get_actor_by_id(vendor2_client, vendor2.id_) _phase_coordinator_suggests_vendor2( + finder_client=finder_client, vendor_client=vendor_client, coordinator_client=coordinator_client, vendor2_client=vendor2_client, diff --git a/vultron/demo/scenario/fvv_demo.py b/vultron/demo/scenario/fvv_demo.py index b9db1e5d2..525abd5a6 100644 --- a/vultron/demo/scenario/fvv_demo.py +++ b/vultron/demo/scenario/fvv_demo.py @@ -266,6 +266,16 @@ def _phase_report_submission( expected_count=4, ) + # CLP-08-005: ensure Finder's genesis hash is seeded before Announce(CaseLedgerEntry) + # is broadcast by the triage cycle below. + with demo_check( + "Finder's DataLayer received case replica before Vendor2 RM triage" + ): + wait_for_case_on_container( + client=finder_client, + case_id=case.id_, + ) + # CM-11-002: Vendor2 joined via invite-accept — run standard RM triage cycle. run_invite_path_rm_triage( invited_client=vendor2_client, From fdb4112981d3645fe6c183325b1a8e2312e9308f Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" <adh@cert.org> Date: Mon, 10 Aug 2026 21:10:17 +0000 Subject: [PATCH 11/12] =?UTF-8?q?history:=20archive=20implementation=20ISS?= =?UTF-8?q?UE-2141=20=E2=80=94=20gate=20invite-path=20RM=20triage=20on=20F?= =?UTF-8?q?inder=20replica=20in=204=20demo=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../history/2608/implementation/ISSUE-2141.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 plan/history/2608/implementation/ISSUE-2141.md diff --git a/plan/history/2608/implementation/ISSUE-2141.md b/plan/history/2608/implementation/ISSUE-2141.md new file mode 100644 index 000000000..220711865 --- /dev/null +++ b/plan/history/2608/implementation/ISSUE-2141.md @@ -0,0 +1,30 @@ +--- +source: ISSUE-2141 +timestamp: '2026-08-10T21:10:09.426979+00:00' +title: gate invite-path RM triage on Finder replica in 4 demo scenarios +type: implementation +--- + +Issue: #2141 — Bug: 4 full-suite demo scenarios have unguarded invite-path CLP-08-005 + +Symptoms: fccv-handoff, fvcv-extension, fccv-extension, fvv demos intermittently +fail with ReconstructChainTailNode raising an unanchored-chain error. Root cause: +the Finder receives Announce(CaseLedgerEntry) broadcast by run_invite_path_rm_triage +before its genesis VulnerabilityCase hash is seeded (CLP-08-005 race). + +Root cause: invite-path RM triage triggers a ledger entry announcement to all +participants including the Finder. If the Finder's genesis VulnerabilityCase +hasn't been replicated yet, the chain tail reconstruction fails. + +Fix: Added wait_for_case_on_container(finder_client, case.id_) immediately before +run_invite_path_rm_triage in 4 phase functions: + +- fccv_handoff_demo._phase_c2_invites_vendor (+ finder_client param added) +- fvcv_extension_demo._phase_coordinator_suggests_vendor2 (+ finder_client param added) +- fccv_extension_demo._phase_c2_suggests_vendor (+ finder_client param added) +- fvv_demo._phase_report_submission (finder_client already present) + +Regression tests added to all 4 test files (TestFinderCaseReplicaWaitBefore*Triage), +verifying signature presence and wait-before-triage ordering. + +PR: <https://github.com/CERTCC/Vultron/pull/2165> From 71cd9c7a829b5d4c4597274da1275fbc6d8485d4 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" <adh@cert.org> Date: Mon, 10 Aug 2026 22:51:02 +0000 Subject: [PATCH 12/12] fix(pr-execute): address 2 findings from triage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test-coverage — add @pytest.mark.xfail to test_actor_target_phrase_no_dangling_dash (SE-07-006 / #1898 / phase10-failing-test-no-xfail-0) - notes-currency — preempt stale check_fix_ready=False note in demo-ci-scenario-coverage.md (PR #2152 removed the param; phase9-stale-note-check-fix-ready-0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- notes/demo-ci-scenario-coverage.md | 9 +++++++++ test/demo/test_report.py | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/notes/demo-ci-scenario-coverage.md b/notes/demo-ci-scenario-coverage.md index 79289331b..376214412 100644 --- a/notes/demo-ci-scenario-coverage.md +++ b/notes/demo-ci-scenario-coverage.md @@ -53,6 +53,15 @@ event types it exercises. Event types are those recorded as `event_type` in `RejectInviteActorToCaseReceivedUseCase` on the CaseActor. Because the Vendor rejects rather than accepts, `accept_invite_actor_to_case` does NOT appear in this scenario. No other current scenario exercises this ledger entry. + **Invariant 15 note**: because the Vendor never participates, no actor advances + the VFD state machine and `vfd_state == 'VFd'` is structurally unreachable. + However, `check_cs_state_transitions_observed()` in + `test/ci/invariants/common.py` no longer accepts a `check_fix_ready` parameter + — the VFd assertion is unconditional as of PR #2152. `test_invariant_15` in + `test_fcv_reject_invariants.py` passes without `check_fix_ready=False` because + `fcv-reject` CI produces a VFd observation in practice. When copy-pasting + Invariant 15 from another harness, do not pass `check_fix_ready=False` — that + parameter no longer exists (DEMOCI-06-001, ISSUE-2121, PR #2152). - `add_case_participant` is emitted by `AcceptInviteNode` (`vultron/core/behaviors/case/nodes/accept_invite.py:181`) on the CaseActor received-side when processing Accept(Invite). This event records the internal diff --git a/test/demo/test_report.py b/test/demo/test_report.py index a2c387654..f98fa41a0 100644 --- a/test/demo/test_report.py +++ b/test/demo/test_report.py @@ -585,6 +585,13 @@ def test_actor_object_phrase_no_dangling_dash(self): result ), f"Un-substituted slot in event_phrase('offer_actor_to_case'): {result!r}" + @pytest.mark.xfail( + reason=( + "SE-07-006: submit_report {target} slot not yet populated by runtime" + " — tracked in #1898, implementation in #2150" + ), + strict=True, + ) def test_actor_target_phrase_no_dangling_dash(self): """Actor+target phrase: both slots filled; no dangling em-dash.""" result = event_phrase("submit_report")