From 2a575750d511a47d69a5c97c41ec121deff920ef Mon Sep 17 00:00:00 2001 From: Dreven Date: Fri, 25 Sep 2026 20:51:17 +0300 Subject: [PATCH 1/8] feat: serve Hungry Crab nutrient --- .crab/attributions.json | 29 +++++++++++++++++++++++++++++ .github/dependabot.yml | 20 ++++++++++++++++++++ THIRD_PARTY_NOTICES.md | 14 ++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 .crab/attributions.json create mode 100644 .github/dependabot.yml create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/.crab/attributions.json b/.crab/attributions.json new file mode 100644 index 0000000..7b720a1 --- /dev/null +++ b/.crab/attributions.json @@ -0,0 +1,29 @@ +{ + "schema": "hungry-crab.attributions/1", + "receipts": [ + { + "nutrient_id": "crab:tooling:tooling.dependabot", + "source": { + "label": "pypa/build", + "url": "https://github.com/pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "license": "MIT" + }, + "taken": [ + { + "maw_path": ".github/dependabot.yml", + "prey_path": ".github/dependabot.yml", + "verbatim": false + } + ], + "mode": "COPY", + "obligation": { + "kind": "copyright-notice", + "text": "keep the source's copyright and permission notice with the material" + }, + "summary": "carried pypa/build's github-actions Dependabot block over unchanged (weekly on Tuesday, one grouped pull request, a seven-day cooldown) and added a pip block for pyproject.toml on the same schedule", + "branch": "crab/tooling-tooling.dependabot-9de61c0c", + "recorded_at": "2026-09-25T17:51:14+00:00" + } + ] +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0256edc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + groups: + github-actions: + patterns: + - "*" + cooldown: + default-days: 7 + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + cooldown: + default-days: 7 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..15e72fc --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +Written by `crab attribution` from `.crab/attributions.json`, the receipts of material +Hungry Crab carried into this repository. Edit the receipts, not this file; rerunning +the command reproduces it byte for byte. + +## pypa/build @ 58c921a — MIT + +Source: +Obligation (copyright-notice): keep the source's copyright and permission notice with the material + +| Nutrient | Into | From | Mode | +|---|---|---|---| +| `crab:tooling:tooling.dependabot` | `.github/dependabot.yml` | `.github/dependabot.yml` (adapted) | COPY | From aae7b21df35f96ba074331482ff37f70f509d222 Mon Sep 17 00:00:00 2001 From: Dreven Date: Fri, 25 Sep 2026 20:51:50 +0300 Subject: [PATCH 2/8] feat: serve Hungry Crab nutrient --- .crab/attributions.json | 29 +++++++++++++++++++++++++++++ .github/workflows/ci.yml | 4 ++++ THIRD_PARTY_NOTICES.md | 14 ++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 .crab/attributions.json create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/.crab/attributions.json b/.crab/attributions.json new file mode 100644 index 0000000..165b7cb --- /dev/null +++ b/.crab/attributions.json @@ -0,0 +1,29 @@ +{ + "schema": "hungry-crab.attributions/1", + "receipts": [ + { + "nutrient_id": "crab:ci:ci.concurrency", + "source": { + "label": "pypa/build", + "url": "https://github.com/pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "license": "MIT" + }, + "taken": [ + { + "maw_path": ".github/workflows/ci.yml", + "prey_path": ".github/workflows/test.yml", + "verbatim": false + } + ], + "mode": "COPY", + "obligation": { + "kind": "copyright-notice", + "text": "keep the source's copyright and permission notice with the material" + }, + "summary": "carried the concurrency block of pypa/build's test workflow into ci.yml, keyed on the ref with cancel-in-progress, so a push supersedes the run of the commit it replaced; nothing else in the workflow changed", + "branch": "crab/ci-ci.concurrency-a444a227", + "recorded_at": "2026-09-25T17:51:47+00:00" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e82e2ac..fdc5324 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..5915508 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +Written by `crab attribution` from `.crab/attributions.json`, the receipts of material +Hungry Crab carried into this repository. Edit the receipts, not this file; rerunning +the command reproduces it byte for byte. + +## pypa/build @ 58c921a — MIT + +Source: +Obligation (copyright-notice): keep the source's copyright and permission notice with the material + +| Nutrient | Into | From | Mode | +|---|---|---|---| +| `crab:ci:ci.concurrency` | `.github/workflows/ci.yml` | `.github/workflows/test.yml` (adapted) | COPY | From 4c7f0a1a93cd3231feea89fb879c0fafe5fdf1fb Mon Sep 17 00:00:00 2001 From: Dreven Date: Wed, 30 Sep 2026 21:56:05 +0300 Subject: [PATCH 3/8] Keep the Hungry Crab configuration and ledger in the repository The Hungry Crab run of 2026-09-25 against pypa/build left its configuration (.crab.yml, all defaults) and its ledger (.crab/ledger.json: one meal, eighteen nutrients, two of them served as #36 and #37) in the working tree, from where a roadmap commit of the 0.1.9 release branch swept them in by accident. They were taken back out of that branch; this commit offers them on their own, byte for byte, because the configuration says the ledger is committed (ledger: repo) and without it the next run would propose the same eighteen nutrients again. Co-Authored-By: Claude Opus 5.5 --- .crab.yml | 45 ++++++ .crab/ledger.json | 361 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 .crab.yml create mode 100644 .crab/ledger.json diff --git a/.crab.yml b/.crab.yml new file mode 100644 index 0000000..ad83a9e --- /dev/null +++ b/.crab.yml @@ -0,0 +1,45 @@ +# Hungry Crab maw configuration. Every key is optional; these are the defaults. +license: null # SPDX id of this repository; detected from LICENSE when null +mode: normal # normal | strict. Strict downgrades COPY to REIMPLEMENT for + # code and copies only configs and templates. Not enforced + # yet: today the setting is accepted and ignored. +hunger: # per nutrient category: true | false | issues-only | ideas-only + security: true + ci: true + tests: true + tooling: true + ai-config: true + hygiene: true + docs: true + deps: true + history-lesson: true + issue-lesson: true + architecture: issues-only + code: ideas-only # declared, and produced by nothing until 0.4: accepted and inert +ignore: [] # globs excluded from this repository's own digest, so that test + # fixtures and vendored trees are not mistaken for your code, e.g. + # [tests/fixtures/**, examples/**]. Patterns are case-sensitive on + # every platform, like the git paths they match. +budget: + policy: warn # warn | enforce | off for the whole digest Markdown budget +serve: + issues: ask # auto | ask | off + prs: ask # auto | ask | off; gates --as pr-branch (REIMPLEMENT and COPY) + max_prs_per_run: 3 + labels: [hungry-crab] + assignees: [] + token_env: "" # environment variable holding the token to file issues as, e.g. + # CRAB_BOT_TOKEN with a GitHub App installation token. Empty means + # gh's own login: your issues carry your name, not the crab's. +trust: # a license is a promise to strangers; these are not strangers + same_owner: true # prey owned by the account that owns this repository is your own code + owners: [] # other accounts whose repositories count as your own, e.g. [acme-inc] + bypass_license: false # last resort: treat every prey as COPY. Every card says so, and the + # verdict is flagged for human review, because this is not a finding + # about the license but a decision to stop asking. +attribution_file: THIRD_PARTY_NOTICES.md + # where COPY records its sources: rendered by `crab attribution` + # from the receipts in .crab/attributions.json, which + # `crab serve --as pr-branch` writes when it carries prey files in. +ledger: repo # repo (.crab/ledger.json, committed) | cache | none +scoring: {} # overrides for data/scoring.yml sections; `crab tune` suggests them diff --git a/.crab/ledger.json b/.crab/ledger.json new file mode 100644 index 0000000..4387df9 --- /dev/null +++ b/.crab/ledger.json @@ -0,0 +1,361 @@ +{ + "schema": "hungry-crab.ledger/1", + "maw": "Devostasis", + "updated_at": "2026-09-25T17:51:54+00:00", + "meals": [ + { + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "url": "https://github.com/pypa/build", + "license": "MIT", + "mode": "COPY", + "date": "2026-09-25T17:47:29+00:00", + "candidates": 18, + "new": 18 + } + ], + "entries": [ + { + "id": "crab:architecture:architecture.pypa-build.raw", + "category": "architecture", + "key": "architecture.pypa-build.raw", + "title": "Architecture of pypa/build: hubs and layering (raw material)", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.08, + "serve_as": "idea", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.cache", + "category": "ci", + "key": "ci.cache", + "title": "Cache dependencies in CI", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.81, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.concurrency", + "category": "ci", + "key": "ci.concurrency", + "title": "Cancel superseded CI runs with concurrency groups", + "status": "served", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.63, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": "2026-09-25T17:51:54+00:00", + "reason": "", + "url": "https://github.com/drevendev/Devostasis/pull/37" + }, + { + "id": "crab:ci:ci.macos-runner", + "category": "ci", + "key": "ci.macos-runner", + "title": "Test on macOS runners", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.26, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.release-automation", + "category": "ci", + "key": "ci.release-automation", + "title": "Automate releases", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.37, + "serve_as": "issue", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.timeouts", + "category": "ci", + "key": "ci.timeouts", + "title": "Set job timeouts", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.54, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.windows-runner", + "category": "ci", + "key": "ci.windows-runner", + "title": "Test on Windows runners", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.44, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:deps:deps.python.others", + "category": "deps", + "key": "deps.python.others", + "title": "38 python dependencies the prey uses and you do not", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.1, + "serve_as": "idea", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:docs:docs.site", + "category": "docs", + "key": "docs.site", + "title": "Publish a documentation site", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.18, + "serve_as": "issue", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:history-lesson:history-lesson.pypa-build.fix-prone", + "category": "history-lesson", + "key": "history-lesson.pypa-build.fix-prone", + "title": "Lessons from pypa/build's history: fix-prone areas", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.25, + "serve_as": "issue", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:hygiene:hygiene.codeowners", + "category": "hygiene", + "key": "hygiene.codeowners", + "title": "Add CODEOWNERS", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.35, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:hygiene:hygiene.pr-template", + "category": "hygiene", + "key": "hygiene.pr-template", + "title": "Add a pull request template", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.35, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:hygiene:hygiene.readme-badges", + "category": "hygiene", + "key": "hygiene.readme-badges", + "title": "README: add status badges", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.21, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:tests:tests.coverage", + "category": "tests", + "key": "tests.coverage", + "title": "Measure test coverage", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.59, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:tooling:tooling.dependabot", + "category": "tooling", + "key": "tooling.dependabot", + "title": "Enable Dependabot updates", + "status": "served", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.68, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": "2026-09-25T17:51:21+00:00", + "reason": "", + "url": "https://github.com/drevendev/Devostasis/pull/36" + }, + { + "id": "crab:tooling:tooling.formatter.black", + "category": "tooling", + "key": "tooling.formatter.black", + "title": "Adopt the formatter black", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.56, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:tooling:tooling.linter.ruff", + "category": "tooling", + "key": "tooling.linter.ruff", + "title": "Adopt the linter ruff", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.56, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:tooling:tooling.pre-commit", + "category": "tooling", + "key": "tooling.pre-commit", + "title": "Add pre-commit hooks", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.56, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + } + ] +} From 2941142ebdffecce40d2a463af16238ea4f75036 Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Fri, 2 Oct 2026 15:53:20 +0300 Subject: [PATCH 4/8] Bind carried Integrity history to verified predecessor contents --- docs/spec/conformance.md | 11 ++++++ docs/spec/integrity-ci.md | 8 +++- src/devostasis/revision_history.py | 6 ++- src/devostasis/runner.py | 11 +++++- tests/test_revision_history.py | 60 ++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index cd648c2..88f2539 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -521,6 +521,17 @@ accepted case. | gauges establish no order | a gauge that moved inside a band is UNCHANGED | `test_a_gauge_that_moved_inside_one_band_is_not_a_direction` | | ordered bands are emittable | every ordered band is one its Vital emits, and the unordered ones are exactly the descriptive and evidence states | `test_every_ordered_band_is_a_band_its_vital_can_emit`, `test_the_bands_left_unordered_are_exactly_the_descriptive_and_evidence_states` | +## Carried-history admission (implementation regressions) + +These regressions enforce the accepted predecessor and immutable-revision +requirements without assigning new research case identifiers. + +| Boundary | Meaning | Test | +| --- | --- | --- | +| carrier content | edited records, attempts, lineage or provenance cannot replace the verified predecessor's history, even with its correct bundle id | `test_a_build_refuses_changed_history_even_when_it_names_the_right_predecessor` | +| exact rebuild | an unchanged admitted carrier reproduces all bundle members and keeps the recorded failure | `test_a_build_accepts_an_unchanged_carrier_and_reproduces_the_same_bundle` | +| duplicate revision | neither current nor replayable carried lineage can overwrite a failure with a second record for the same revision | `test_duplicate_carried_revisions_cannot_overwrite_a_recorded_failure` | + ## The vector runner (target B1) | Case | Meaning | Test | diff --git a/docs/spec/integrity-ci.md b/docs/spec/integrity-ci.md index c36829e..7b854ad 100644 --- a/docs/spec/integrity-ci.md +++ b/docs/spec/integrity-ci.md @@ -135,7 +135,13 @@ is carried and the gap is explicit (`REVISION_HISTORY_GAP`, HIST-08, -18, -20). `verify` checks that the source a bundle names is the bundle its manifest follows (`HISTORY_SOURCE_MISMATCH`), and a build refuses observations that already carry history from another bundle -(`HISTORY_SOURCE_NOT_PREDECESSOR`). +(`HISTORY_SOURCE_NOT_PREDECESSOR`). A stated carrier must also match the +canonical observation reconstructed from that verified predecessor, including +its records and provenance (`HISTORY_CONTENT_MISMATCH`); an unchanged source +id never authorizes replacement or deletion of a recorded attempt. Carried +records name each nonempty immutable revision once. Duplicate revision ids +are malformed history (`REVISION_HISTORY_CARRY_MALFORMED`), rather than a +last-record-wins override of an earlier failure. **Reconciliation**, per revision of the current 14-day inventory: the carried union and the provider's current parents are merged by parent identity, and diff --git a/src/devostasis/revision_history.py b/src/devostasis/revision_history.py index 518e203..8c24663 100644 --- a/src/devostasis/revision_history.py +++ b/src/devostasis/revision_history.py @@ -248,10 +248,14 @@ def _carried_records(value: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: "basis": value.get("basis"), } records: list[dict[str, Any]] = [] + seen: set[str] = set() for position, record in enumerate(raw): where = f"carried record {position}" - if not isinstance(record, dict) or not isinstance(record.get("revision"), str) or not isinstance(record.get("committed_at"), str): + if not isinstance(record, dict) or not isinstance(record.get("revision"), str) or not record["revision"] or not isinstance(record.get("committed_at"), str): raise HistoryShapeError(f"{where}: no revision or committed_at") + if record["revision"] in seen: + raise HistoryShapeError(f"{where}: duplicate revision {record['revision']}") + seen.add(record["revision"]) timeutil.parse_ts(record["committed_at"]) if lineage == LINEAGE: parents = parents_from_groups(record.get("parent_groups") or [], where) diff --git a/src/devostasis/runner.py b/src/devostasis/runner.py index b81684f..86adfb5 100644 --- a/src/devostasis/runner.py +++ b/src/devostasis/runner.py @@ -138,6 +138,7 @@ def check_receipt_config(project: ResolvedProject, obs: ObservationSet) -> None: HISTORY_SOURCE_NOT_PREDECESSOR = "HISTORY_SOURCE_NOT_PREDECESSOR" +HISTORY_CONTENT_MISMATCH = "HISTORY_CONTENT_MISMATCH" def attach_revision_history(obs: ObservationSet, predecessor: Predecessor) -> ObservationSet: @@ -146,8 +147,9 @@ def attach_revision_history(obs: ObservationSet, predecessor: Predecessor) -> Ob The source is the bundle the comparison resolved, the immediate predecessor, never an older one. A set that already states carried history (rebuilt from a bundle's own observations) is kept only when it names that - same predecessor: carried history is canonical input, and consuming it - against another chain would break the chain it records. + same predecessor and exactly matches the observation reconstructed from + its verified contents. Naming a source alone cannot prove its history: + accepting edited records would let an earlier failure disappear. """ carried = revision_history.carried_observation( predecessor.status, @@ -167,6 +169,11 @@ def attach_revision_history(obs: ObservationSet, predecessor: Predecessor) -> Ob f"{revision_history.carried_source_bundle(obs) or 'no bundle'} ({stated.status}), this store's predecessor is " f"{predecessor.bundle_id or 'none'} ({predecessor.status}); durable history is consumed only from the immediate predecessor" ) + if canonical.canonical_bytes(stated.to_dict()) != canonical.canonical_bytes(carried.to_dict()): + raise BundleError( + f"{HISTORY_CONTENT_MISMATCH}: the stated revision history does not match the observation reconstructed " + f"from the verified immediate predecessor {predecessor.bundle_id or 'none'}; records and provenance cannot be substituted" + ) return obs diff --git a/tests/test_revision_history.py b/tests/test_revision_history.py index 8bfe3ed..ca112ce 100644 --- a/tests/test_revision_history.py +++ b/tests/test_revision_history.py @@ -8,6 +8,7 @@ from __future__ import annotations +import copy import shutil import pytest @@ -228,6 +229,65 @@ def test_a_build_refuses_observations_that_carry_history_from_another_predecesso build_from_observations(single_project(LOCATOR, config_version="1"), replayed, store) +@pytest.mark.parametrize("changed", ["records", "attempts", "lineage", "source_observed_at", "basis", "freshness", "source_ref"]) +def test_a_build_refuses_changed_history_even_when_it_names_the_right_predecessor(tmp_path, changed): + """Naming the verified predecessor is not proof of the history it actually contains.""" + from devostasis.runner import attach_revision_history, find_predecessor + + store = FilesystemHistoryStore(tmp_path) + _, previous, _ = _chain(store) + project = single_project(LOCATOR, config_version="1") + obs = _obs("2026-09-08T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_PASS")])])]) + predecessor = find_predecessor(store, project, PROJECT_ID, obs.observed_at) + obs = attach_revision_history(obs, predecessor) + stated = copy.deepcopy(obs.get(CARRIED).to_dict()) + assert stated["value"]["source_bundle_id"] == previous.bundle_id + if changed == "records": + stated["value"]["records"] = [] + elif changed == "attempts": + failed = next(r for r in stated["value"]["records"] if r["revision"] == "r") + failed["parent_groups"][0]["attempts"] = [{"attempt": 1, "state": "VERIFY_PASS"}] + elif changed in ("lineage", "source_observed_at", "basis"): + stated["value"][changed] = "forged" + else: + stated[changed] = "STALE" if changed == "freshness" else "bundle:forged" + obs.replace(Observation.from_dict(stated)) + with pytest.raises(BundleError, match="HISTORY_CONTENT_MISMATCH"): + build_from_observations(project, obs, store) + + +def test_a_build_accepts_an_unchanged_carrier_and_reproduces_the_same_bundle(tmp_path): + import json + + store = FilesystemHistoryStore(tmp_path) + _chain(store) + project = single_project(LOCATOR, config_version="1") + obs = _obs("2026-09-08T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_PASS")])])]) + built = build_from_observations(project, obs, store) + replayed = ObservationSet.from_dict(json.loads(built.members["observations.json"])) + rebuilt = build_from_observations(project, replayed, store) + assert rebuilt.bundle_id == built.bundle_id + assert rebuilt.members == built.members + assert _states(rebuilt)["r"] == "FAILURE_OBSERVED" + + +@pytest.mark.parametrize("lineage", [LINEAGE, REPLAYABLE_LINEAGE]) +def test_duplicate_carried_revisions_cannot_overwrite_a_recorded_failure(lineage): + from devostasis.revision_history import encode, parent_from_current, union_record + from devostasis.vitals import integrity + + obs = _obs("2026-09-08T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_PASS")])])]) + failed = _record("r", 4, [_run(RUN_R, [(1, "VERIFY_FAIL")])]) + passed = _record("r", 4, [_run(RUN_R, [(1, "VERIFY_PASS")])]) + records = [failed, passed] + if lineage == LINEAGE: + records = [encode(union_record(r["revision"], r["committed_at"], [parent_from_current(p, "test") for p in r["parents"]], [])) for r in records] + add(obs, CARRIED, {"lineage": lineage, "records": records}, "record") + result = integrity.evaluate(obs) + assert (result.band, result.evaluation_status) == (None, "UNKNOWN") + assert any(code.startswith("REVISION_HISTORY_CARRY_MALFORMED") for code in result.diagnostics) + + def test_a_build_leaves_the_callers_observation_set_as_it_was(tmp_path): store = FilesystemHistoryStore(tmp_path) obs = _obs("2026-09-06T12:00:00Z", _passing([1, 2, 3])) From 7b328f062a172857d59e3a26a980d67259c45486 Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Fri, 2 Oct 2026 15:53:21 +0300 Subject: [PATCH 5/8] Reconcile 0.2.0 release state, maintenance and next consumer roadmap --- .devostasis/debt.json | 4 +-- .devostasis/targets.json | 4 +-- .github/dependabot.yml | 3 ++ CHANGELOG.md | 21 ++++++++++++- CONTRIBUTING.md | 3 ++ LICENSES/pypa-build-MIT.txt | 20 +++++++++++++ ROADMAP.md | 60 ++++++++++++++++++++++++++++++++++--- THIRD_PARTY_NOTICES.md | 7 +++-- 8 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 LICENSES/pypa-build-MIT.txt diff --git a/.devostasis/debt.json b/.devostasis/debt.json index 768ea04..f8fe7cd 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -7,8 +7,8 @@ "title": "Conformance cases named in the specification have no executable fixture", "state": "open", "opened": "2026-09-05", - "updated": "2026-09-30", - "cost": "docs/spec/conformance.md named T2-T9, R1-R53, ART-05, ART-08-ART-11, ART-15 and RPT-4-RPT-9 with no test behind them, so the public coverage claim is larger than the proof. The vector format and runner exist since 0.1.8, and 0.1.9 transcribed the accepted exact vectors the research process delivered (T2, R1, R2, R3, R4, T4, T5, T7) together with whole accepted families (INT-UNKNOWN, INT-TOTAL, CLU-INCOMPLETE, ACT-COV); sixty-three of the seventy named cases still have no evidence, and several (T3, T6, T8, the bundle and store cases) need a conformance surface this format does not yet carry (issues #20, #23)." + "updated": "2026-10-02", + "cost": "docs/spec/conformance.md named T2-T9, R1-R53, ART-05, ART-08-ART-11, ART-15 and RPT-4-RPT-9 with no test behind them, so the public coverage claim is larger than the proof. The vector format and runner exist since 0.1.8, and 0.1.9 transcribed the accepted exact vectors the research process delivered (T2, R1, R2, R3, R4, T4, T5, T7) together with whole accepted families (INT-UNKNOWN, INT-TOTAL, CLU-INCOMPLETE, ACT-COV). The pending 0.2.0 branch holds T9 to its accepted table with a generator, leaving sixty-two named cases without executable proof. Several (T3, T6, T8, the bundle and store cases) need further conformance capabilities or accepted vectors (issues #20, #23)." }, { "id": "D-2", diff --git a/.devostasis/targets.json b/.devostasis/targets.json index 72103eb..61b990f 100644 --- a/.devostasis/targets.json +++ b/.devostasis/targets.json @@ -45,11 +45,11 @@ { "id": "v0.2", "title": "Release 0.2: phases A and B closed, one outside consumer, no unadopted research unit", - "state": "closed" + "state": "open" }, { "id": "v0.3", - "title": "Release 0.3: phases A and B closed, one outside consumer, no unadopted research unit (the bar 0.2.0 shipped before)", + "title": "Release 0.3: phases A and B closed, one outside consumer, no unadopted research unit (the bar deferred from the 0.2.0 adoption release)", "state": "open" }, { diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0256edc..0bff239 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,6 +2,8 @@ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" + # Feature and maintenance PRs target the active release, per CONTRIBUTING.md. + target-branch: "release/0.2.0" schedule: interval: "weekly" day: "tuesday" @@ -13,6 +15,7 @@ updates: default-days: 7 - package-ecosystem: "pip" directory: "/" + target-branch: "release/0.2.0" schedule: interval: "weekly" day: "tuesday" diff --git a/CHANGELOG.md b/CHANGELOG.md index d200e41..1e49773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,26 @@ All notable changes to this project are documented here. Semantic changes to a contract or a policy always come with a version bump of that contract. -## 0.2.0 (2026-10-01) +## 0.2.0 (unreleased) + +Release-readiness review, 2026-10-02: + +- A saved carried-history observation is admitted only when its canonical + content and provenance match what the verified immediate predecessor + supplies. Previously the correct source bundle id and acquisition status + were enough to admit edited records, including removal of a prior failure. + The build now refuses those inputs with `HISTORY_CONTENT_MISMATCH`. +- A duplicate carried revision id fails closed with + `REVISION_HISTORY_CARRY_MALFORMED`, in both the current carrier and replay + from 0.1.x. Previously the last duplicate could overwrite an observed + failure. These are admission repairs under the existing accepted history + contract; valid evidence, contract ids, thresholds and windows are unchanged. +- Pending maintenance from #36, #37 and #50 is integrated into the release: + weekly Dependabot updates, cancellation of superseded CI runs, and the + Hungry Crab configuration and ledger. The two attribution receipts are + retained together rather than losing one to their add/add conflict. +- The roadmap distinguishes the tagged 0.1.9 release from the pending 0.2.0 + adoption branch and names the next consumer-facing development sequence. The adoption release. Every accepted research contract about the Vitals and their history that this repository had not adopted, and that needs no owner diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 978d5b9..a6e1c21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,9 @@ runtime deterministic, model-free and honest about missing evidence. `release/0.1.0`). Feature branches target the current release branch. - When a release branch is complete and green, it is merged into `master` with a merge commit and tagged `v`. +- Dependabot version-update PRs also target the active release branch. When + opening the next release, advance both `target-branch` entries in + `.github/dependabot.yml`; do not send maintenance directly to `master`. ## Linking a pull request to a target diff --git a/LICENSES/pypa-build-MIT.txt b/LICENSES/pypa-build-MIT.txt new file mode 100644 index 0000000..c3713cd --- /dev/null +++ b/LICENSES/pypa-build-MIT.txt @@ -0,0 +1,20 @@ +Copyright © 2019 Filipe Laíns + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index 0700369..3723810 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,58 @@ Ordered by what unblocks what, not by what is interesting. Every item names who or what blocks it, so a reader can tell the difference between work not started and work that cannot start. +## Release state and next steps — 2026-10-02 + +The latest tagged release on `master` is **v0.1.9**. **0.2.0 is prepared, +not released**: [#53](https://github.com/drevendev/Devostasis/pull/53) is still +open and there is no `v0.2.0` tag. The adoption statements below describe the +implementation on `release/0.2.0`, not availability on `master`. Phase A is +closed; Phase B has four completed items in the released version and five +in this branch, where B3 is implemented. B1 and B7 remain open. The +specification still names 62 cases without executable proof in this branch. + +The next work is ordered by the evidence each step makes possible: + +1. **Finish and release 0.2.0.** Admit only the carried history reconstructed + from the verified predecessor, reject duplicate carried revision ids, and + reconcile the pending maintenance branches. Merge the release only after + review and CI, then tag it; an unreleased branch is not a completed release. +2. **Restore sustained observation and complete Phase B.** The latest public + evidence in [#32](https://github.com/drevendev/Devostasis/issues/32) records + an Actions billing refusal and a fleet history ending on 2026-09-10; check + and restore the private observer before claiming fresh calibration data. + Repair the GitHub run-listing ceiling + ([#51](https://github.com/drevendev/Devostasis/issues/51)), keep external + check-app history uncertainty explicit + ([#52](https://github.com/drevendev/Devostasis/issues/52)), adopt the + compatibility policy, finish B1, and run a B7 pilot selected by the owner. +3. **Deliver the accepted reach contracts in small packages.** GitLab, + Coverage, TestState, Deployment and Work follow the existing Phase C gates. + The additional CLI and Explorer composition in + [#47](https://github.com/drevendev/Devostasis/issues/47) still needs its + independent G1 judgement; acceptance of its component contracts does not + accept the whole candidate. +4. **Make the evidence usable as bounded work.** The proposed consumer + work-scope surface in [#54](https://github.com/drevendev/Devostasis/issues/54) + projects five typed queues (`review`, `finish_merge`, `implement_issue`, + `research`, `analyze_code`) with stable ids, exact revisions, explicit + consumer priority, acceptance, dependencies, freshness and coverage. + [#55](https://github.com/drevendev/Devostasis/issues/55) supplies + revision-bound findings and source slices; + [#56](https://github.com/drevendev/Devostasis/issues/56) supplies the GitLab + CI and durable handoff recipe. These are proposals requiring a contract, + not implemented or accepted core semantics. A companion consumer library + may keep task selection outside the observational engine. + +The recommended next major product increment is **Evidence to Action**: +trusted observations plus reproducible, bounded work scopes for an outside +consumer. Its success criterion is a developer or agent selecting one task +from verified evidence, recovering explicitly missing input, and verifying +the stated acceptance without rediscovering the whole repository. GitLab +and the Instruments expand that evidence; a presentation-only release does +not satisfy this consumer criterion. No queue grants permission to merge, +claim or deploy, and business priority remains explicit consumer policy. + ## How this roadmap is worked ```text @@ -57,7 +109,7 @@ statement; the fix belongs in this repository, not in the rule. | Accepted judgements not yet adopted | **this repository, and the owner for one** | the Phase C contracts of [#34](https://github.com/drevendev/Devostasis/issues/34) (GitLab, instruments, the compatibility policy), and `PV-REV-RECEIPT-IDENTITY-003` on [#19](https://github.com/drevendev/Devostasis/issues/19), the fifth identity move, which the owner has to want. The Vital repairs of [#33](https://github.com/drevendev/Devostasis/issues/33) and B3 are adopted in 0.2.0 | | Audit handoffs | this repository | about forty `REPAIR REQUIRED` static audits since 2026-09-20, catalogued in [#35](https://github.com/drevendev/Devostasis/issues/35); 0.1.9 repairs the store, transport, decoder and payload families, the timestamp and lineage families are open | | Review of 2026-09-30 | this repository, research for four | [#48](https://github.com/drevendev/Devostasis/issues/48): its defects are repaired in 0.1.9; what needs work or a decision is #38 to #46, four of them `for:researcher` (#39, #40, #42, #43) | -| "Evidence Observatory" candidate | research review, then this repository | the owner asked for a large next release on 2026-09-30; 0.2.0 shipped the part of it that rests on accepted contracts, and the candidate specification (`PV-RELEASE-020-001`, not yet accepted) keeps the rest; [#47](https://github.com/drevendev/Devostasis/issues/47) maps its sixteen packages onto the issues here | +| "Evidence Observatory" candidate | research review, then this repository | the owner asked for a large next release on 2026-09-30; the pending 0.2.0 branch implements the part that rests on accepted contracts, and the candidate specification (`PV-RELEASE-020-001`, not yet accepted) keeps the rest; [#47](https://github.com/drevendev/Devostasis/issues/47) maps its sixteen packages onto the issues here | The four judgements the research process owed this repository about work already shipped have all been delivered: @@ -279,9 +331,9 @@ trustworthy. **0.2 was defined here as** Phase A and Phase B closed, which includes one consumer outside this repository depending on a bundle, and no accepted -research unit waiting for adoption. 0.2.0 shipped on 2026-10-01 before that, -at the owner's request for a large release (#47), as the release that adopts -every accepted Vital and history contract. What the definition still asks for, +research unit waiting for adoption. The pending 0.2.0 adoption release is +scoped to ship before that, at the owner's request for a large release (#47), +with the accepted Vital repairs and durable history. What the definition still asks for, B7 and the adoption of the Phase C contracts of #34, is now the bar for 0.3. **1.0 is reached when** the contract identifiers have a compatibility policy, diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f86b9f1..e7fe15a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,8 +1,9 @@ # Third-party notices -Written by `crab attribution` from `.crab/attributions.json`, the receipts of material -Hungry Crab carried into this repository. Edit the receipts, not this file; rerunning -the command reproduces it byte for byte. +Attribution receipts are recorded in `.crab/attributions.json` for material +Hungry Crab carried into this repository. Both pending maintenance branches' +receipts are retained here. The upstream copyright and permission notice is +preserved in [LICENSES/pypa-build-MIT.txt](LICENSES/pypa-build-MIT.txt). ## pypa/build @ 58c921a — MIT From 2f64a40f1ac6127b04a29aa7dd5fad4ada5e2173 Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Fri, 2 Oct 2026 15:54:01 +0300 Subject: [PATCH 6/8] Remove trailing whitespace from the combined attribution notice --- THIRD_PARTY_NOTICES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e7fe15a..551f36c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,7 @@ preserved in [LICENSES/pypa-build-MIT.txt](LICENSES/pypa-build-MIT.txt). ## pypa/build @ 58c921a — MIT -Source: +Source: Obligation (copyright-notice): keep the source's copyright and permission notice with the material | Nutrient | Into | From | Mode | From 2003a9e8bfbf078f538ea9722d028aa771d1b82d Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 14:16:18 +0300 Subject: [PATCH 7/8] Preserve trusted CI history across inadmissible current observations --- docs/spec/integrity-ci.md | 19 +++++ src/devostasis/activity.py | 7 +- src/devostasis/revision_history.py | 62 ++++++++++++--- src/devostasis/vitals/integrity.py | 31 +++++++- tests/test_ci_history_admission.py | 121 +++++++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 tests/test_ci_history_admission.py diff --git a/docs/spec/integrity-ci.md b/docs/spec/integrity-ci.md index 7b854ad..3873595 100644 --- a/docs/spec/integrity-ci.md +++ b/docs/spec/integrity-ci.md @@ -157,6 +157,25 @@ When the current series is partial or unusable, carried records are kept, so history crosses a bundle whose verification evidence could not be read. A current verdict is never carried (HIST-16). +Current inventory admission validates every row before treating absence as +branch departure. Duplicate current ids are diagnosed as +`REVISION_RECORD_DUPLICATE:`; malformed rows and contradictory +coverage prevent a completeness claim. Ambiguous current records cannot +replace carried facts. Current timestamps are parsed before reconciliation; +a different instant for a known revision yields +`IMMUTABLE_COMMIT_TIME_MISMATCH` and preserves its prior time and attempts. +Equivalent timezone representations name the same instant. + +Attempt completeness requires exactly the observed identities 1..latest, +with work bounded by the observed cardinality. A missing or extra identity +cannot establish completeness. Unknown state vocabulary is malformed input; +the legitimate `UNKNOWN` outcome is retained as incomplete history and never +proves absence of failure. Successive states of one attempt, including +`VERIFY_UNRESOLVED` then `VERIFY_PASS`, remain valid observations. An observed +failure remains absorbing even when the rest of history is incomplete. +These are admission repairs within the existing history lineage and accepted +rules, not new thresholds, external-check semantics or a migration. + **Lineage.** A carried union of `devostasis.ci-history.v2` is read as it is. The revision records a 0.1.x bundle keeps in `ci.revision_verdicts_14d` (`devostasis.ci-history.v1`) are replayed from the attempts they kept, never diff --git a/src/devostasis/activity.py b/src/devostasis/activity.py index 70126a1..b15f5a4 100644 --- a/src/devostasis/activity.py +++ b/src/devostasis/activity.py @@ -14,6 +14,7 @@ from .normalize import CI_REVISIONS, INV_COMMITS, INV_CRS, INV_ISSUES, INV_RELEASES from .observations import ObservationSet from .policy import ACTIVITY +from .revision_history import _series_records BASIS_PREVIOUS = "PREVIOUS_BUNDLE" BASIS_WINDOW = "OBSERVATION_WINDOW_28D" @@ -130,7 +131,11 @@ def build_activity( verification_items: list[dict[str, Any]] = [] verified = failed = unresolved = 0 if ci is not None and ci.has_value: - for rev in ci.value: + admitted, _, problems = _series_records(ci) + coverage_notes.extend(f"VERIFICATION_INPUT_INADMISSIBLE:{problem}" for problem in problems) + if admitted is None: + coverage_notes.append(f"VERIFICATION:{ci.status}:{ci.freshness}") + for rev in admitted or []: if not rev.get("parents") or not _in_interval(rev.get("committed_at"), start, end): continue verified += 1 diff --git a/src/devostasis/revision_history.py b/src/devostasis/revision_history.py index 8c24663..e7c8719 100644 --- a/src/devostasis/revision_history.py +++ b/src/devostasis/revision_history.py @@ -67,6 +67,7 @@ CONTRIBUTION = {FAILURE_OBSERVED: "VERIFY_FAIL", PASS_ONLY_OBSERVED: "VERIFY_PASS", NO_DECISIVE_OBSERVED: None, UNKNOWN_HISTORY: None} ACTIONS_KIND = "github_actions_workflow_run" +ATTEMPT_STATES = {"VERIFY_PASS", "VERIFY_FAIL", "VERIFY_UNRESOLVED", "NON_VERIFY_TERMINAL", "NOT_EXECUTED", "UNKNOWN"} # Where the carried history came from. SOURCE_CARRIED = "CARRIED" @@ -102,6 +103,8 @@ def _attempts(raw: Any, where: str) -> list[dict[str, Any]]: for attempt in raw: if not isinstance(attempt, dict) or not isinstance(attempt.get("state"), str): raise HistoryShapeError(f"{where}: an attempt carries no state") + if attempt["state"] not in ATTEMPT_STATES: + raise HistoryShapeError(f"{where}: unknown attempt state {attempt['state']!r}") number = attempt.get("attempt") if number is not None and (isinstance(number, bool) or not isinstance(number, int) or number < 1): raise HistoryShapeError(f"{where}: attempt number {number!r} is not a positive integer") @@ -183,10 +186,15 @@ def merge_parents(*groups: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], def parent_complete(parent: dict[str, Any]) -> bool: """Every attempt of an Actions run, 1..latest, is in the union. A check suite never is.""" latest = parent.get("latest_attempt") - if parent.get("kind") != ACTIONS_KIND or not isinstance(latest, int) or latest < 1: + if parent.get("kind") != ACTIONS_KIND or type(latest) is not int or latest < 1: return False - numbers = {a["attempt"] for a in parent["attempts"] if isinstance(a.get("attempt"), int)} - return set(range(1, latest + 1)) <= numbers + attempts = parent["attempts"] + if any(type(a.get("attempt")) is not int or not 1 <= a["attempt"] <= latest + or a.get("state") not in ATTEMPT_STATES - {"UNKNOWN"} for a in attempts): + return False + # Bound work by observed evidence, never by an untrusted declared maximum. + # Several successive states of one attempt still represent one identity. + return len({a["attempt"] for a in attempts}) == latest def union_state(parents: list[dict[str, Any]], unresolved: list[str]) -> tuple[str, bool]: @@ -299,11 +307,40 @@ def output(self) -> dict[str, Any]: return {"lineage": LINEAGE, "source": dict(self.source), "records": [encode(record) for record in ordered]} -def _series_records(series: Observation | None) -> tuple[list[dict[str, Any]] | None, bool]: +def _series_records(series: Observation | None) -> tuple[list[dict[str, Any]] | None, bool, list[str]]: """The current revision records and whether they are the whole 14-day inventory.""" if series is None or series.status not in (AVAILABLE, PARTIAL) or series.freshness != FRESH or not isinstance(series.value, list): - return None, False - return [r for r in series.value if isinstance(r, dict) and isinstance(r.get("revision"), str)], series.status == AVAILABLE + return None, False, [] + records, problems, seen, duplicates = [], [], set(), set() + for position, record in enumerate(series.value): + ref = record.get("revision") if isinstance(record, dict) else None + if isinstance(ref, str) and ref: + if ref in seen: + duplicates.add(ref) + seen.add(ref) + try: + if not isinstance(record, dict) or not isinstance(ref, str) or not ref: + raise HistoryShapeError("no immutable revision") + if not isinstance(record.get("committed_at"), str): + raise HistoryShapeError("no committed_at") + timeutil.parse_ts(record["committed_at"]) + if not isinstance(record.get("parents"), list): + raise HistoryShapeError("parents is not a list") + for parent in record["parents"]: + parent_from_current(parent, f"revision {ref}") + if (record.get("history_state") not in CONTRIBUTION + or record.get("historical_contribution") != CONTRIBUTION[record["history_state"]] + or not isinstance(record.get("current_verdict"), (str, type(None)))): + raise HistoryShapeError("inconsistent history contribution or current verdict") + except (HistoryShapeError, ValueError, TypeError) as exc: + problems.append(f"REVISION_RECORD_INCONSISTENT:{ref if isinstance(ref, str) else position}:{exc}") + continue + records.append(record) + problems.extend(f"REVISION_RECORD_DUPLICATE:{ref}" for ref in sorted(duplicates)) + if series.status == AVAILABLE and isinstance(series.coverage, dict) and any(series.coverage.get(key) is False + for key in ("complete", "runs_complete", "attempts_complete", "suites_complete")): + problems.append("REVISION_SERIES_COVERAGE_INCOMPLETE") + return [r for r in records if r["revision"] not in duplicates], series.status == AVAILABLE and not problems, problems def reconcile(obs: ObservationSet) -> Reconciled: @@ -332,7 +369,8 @@ def reconcile(obs: ObservationSet) -> Reconciled: source = {"status": SOURCE_MALFORMED} problems.append(f"REVISION_HISTORY_CARRY_MALFORMED:{exc}") - current, inventory_complete = _series_records(obs.get(REVISIONS)) + current, inventory_complete, current_problems = _series_records(obs.get(REVISIONS)) + problems.extend(current_problems) current_by_rev = {r["revision"]: r for r in current or []} records: dict[str, dict[str, Any]] = {} @@ -345,15 +383,17 @@ def reconcile(obs: ObservationSet) -> Reconciled: for revision, record in current_by_rev.items(): committed_at = record.get("committed_at") - if not isinstance(committed_at, str): - problems.append(f"REVISION_RECORD_INCONSISTENT:{revision}") - continue + prior = records.get(revision) + if prior: + if timeutil.parse_ts(committed_at) != timeutil.parse_ts(prior["committed_at"]): + problems.append(f"IMMUTABLE_COMMIT_TIME_MISMATCH:{revision}") + continue + committed_at = prior["committed_at"] try: visible = [parent_from_current(p, f"revision {revision}") for p in record.get("parents") or []] except HistoryShapeError as exc: problems.append(f"REVISION_RECORD_INCONSISTENT:{revision}:{exc}") continue - prior = records.get(revision) prior_parents = prior["parents"] if prior else [] unresolved = list(prior.get("unresolved") or []) if prior else [] if unresolved and visible and all(parent_complete(p) for p in visible): diff --git a/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index 1430dc2..ba69dd3 100644 --- a/src/devostasis/vitals/integrity.py +++ b/src/devostasis/vitals/integrity.py @@ -194,6 +194,25 @@ def _bearing(history: Reconciled, revisions: list[dict[str, Any]]) -> list[dict[ return [r for r in records if r["parents"] or r.get("unresolved")] +def _unknown_current(revisions: list[dict[str, Any]], diagnostics: list[str]) -> dict[str, Any]: + """Retain the accepted current-verdict diagnostic beside an independent admission/history gap.""" + active = [r for r in revisions if _record_consistent(r) and r.get("parents") + and isinstance(r.get("committed_at"), str)] + if not active: + return {} + latest = _newest(active) + current = latest.get("current_verdict") or VERDICT_UNKNOWN + if current not in VERDICTS: + diagnostics.append(f"CURRENT_VERDICT_UNRECOGNIZED:{current}") + current = VERDICT_UNKNOWN + if current != VERDICT_UNKNOWN: + return {} + diagnostics.append(f"CURRENT_VERDICT_UNKNOWN:{latest['revision']}") + return {"unknown_verdict_rule": UNKNOWN_RULE, "current_revision": { + "revision": latest["revision"], "committed_at": latest["committed_at"], + "current_verdict": current, "history_state": latest["history_state"]}} + + def evaluate(obs: ObservationSet) -> VitalResult: diagnostics: list[str] = [] reconciled = reconcile(obs) @@ -230,7 +249,7 @@ def evaluate(obs: ObservationSet) -> VitalResult: # shape its lineage declares, is a coverage defect, not a pass. diagnostics.extend(f"REVISION_RECORD_INCONSISTENT:{_record_label(r)}" for r in inconsistent) diagnostics.extend(reconciled.problems) - return _unknown(obs, history, diagnostics) + return _unknown(obs, {**history, **_unknown_current(revisions, diagnostics)}, diagnostics) active = [r for r in revisions if r.get("parents")] provenance: dict[str, int] = {} for r in active: @@ -276,8 +295,16 @@ def evaluate(obs: ObservationSet) -> VitalResult: # R52 and PV-HIST-002: history nobody can prove is neither a pass nor a # zero, and T9 puts it above every conservative branch. diagnostics.extend(f"REVISION_HISTORY_UNKNOWN:{r['revision']}" for r in unknown_history) + current_gap = _unknown_current(revisions, diagnostics) + if current_gap: + current_gap["failure_ratio_14d"] = ratio(f, n) if n else None + strength = sample_strength(n) + if strength is not None: + current_gap["sample_strength"] = strength + if strength == SAMPLE_SPARSE: + diagnostics.append(SPARSE_DIAGNOSTIC) return _result( - obs, None, EVAL_UNKNOWN, None, None, {**counts, **history}, diagnostics, + obs, None, EVAL_UNKNOWN, None, None, {**counts, **history, **current_gap}, diagnostics, f"The verification history of {len(unknown_history)} recent revisions cannot be proven complete; {f} of {n} decisive revisions failed, and no band is claimed around the unknown ones.", ) diff --git a/tests/test_ci_history_admission.py b/tests/test_ci_history_admission.py new file mode 100644 index 0000000..c519c3e --- /dev/null +++ b/tests/test_ci_history_admission.py @@ -0,0 +1,121 @@ +"""Release-review regressions at real observation, evaluator and bundle boundaries.""" + +from copy import deepcopy +import json + +import pytest + +from devostasis.bundle import verify_dir +from devostasis.config import single_project +from devostasis.history import FilesystemHistoryStore +from devostasis.observations import ObservationSet +from devostasis.revision_history import ( + CARRIED, LINEAGE, REPLAYABLE_LINEAGE, parent_from_current, union_state, +) +from devostasis.runner import build_from_observations +from devostasis.vitals import integrity +from helpers import add +from test_revision_history import ( + LOCATOR, _build, _integrity, _obs, _passing, _record, _run, _states, +) + + +@pytest.mark.parametrize("conflicting,reverse", [(False, False), (True, False), (True, True)]) +def test_duplicate_current_revisions_never_improve_sample_or_overwrite_failure(tmp_path, conflicting, reverse): + rows = _passing([1, 2, 3]) + duplicate = deepcopy(rows[0]) if not conflicting else _record("p1", 1, [_run(801, [(1, "VERIFY_FAIL")])]) + rows.append(duplicate) + if reverse: + rows.reverse() + obs = ObservationSet.from_dict(_obs("2026-09-08T12:00:00Z", rows).to_dict()) + original = deepcopy(obs.to_dict()) + store = FilesystemHistoryStore(tmp_path) + project = single_project(LOCATOR, config_version="1") + bundle = build_from_observations(project, obs, store) + result = _integrity(bundle) + assert (result["band"], result["evaluation_status"]) == (None, "UNKNOWN") + assert "REVISION_RECORD_DUPLICATE:p1" in result["diagnostics"] + assert obs.to_dict() == original + assert verify_dir(store.commit(bundle)) == [] + saved = ObservationSet.from_dict(json.loads(bundle.members["observations.json"])) + assert build_from_observations(project, saved, FilesystemHistoryStore(tmp_path / "replay")).members == bundle.members + + +@pytest.mark.parametrize("bad", [None, [], {}, {"revision": "r"}, {"revision": "", "committed_at": "2026-09-04T10:00:00Z"}, + {"revision": 7, "committed_at": "2026-09-04T10:00:00Z"}, {"revision": "r", "committed_at": None}, + {"revision": "r", "committed_at": "invalid"}]) +def test_malformed_current_inventory_cannot_prove_carried_failure_left_branch(tmp_path, bad): + store = FilesystemHistoryStore(tmp_path) + _build(store, "2026-09-06T12:00:00Z", [_record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])])]) + bundle, path = _build(store, "2026-09-07T12:00:00Z", _passing([1, 2, 3, 5]) + [bad]) + assert _integrity(bundle)["evaluation_status"] == "UNKNOWN" + assert _states(bundle)["r"] == "FAILURE_OBSERVED" + assert verify_dir(path) == [] + + +@pytest.mark.parametrize("bad_time", ["invalid", "2026-08-01T10:00:00Z", "2026-10-01T10:00:00Z"]) +def test_four_bundles_preserve_failure_after_rejected_immutable_timestamp(tmp_path, bad_time): + store = FilesystemHistoryStore(tmp_path) + passing = _passing([1, 2, 3, 5]) + failed = _record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])]) + _build(store, "2026-09-06T12:00:00Z", passing + [failed]) + corrupted = _record("r", 4, [_run(41, [(2, "VERIFY_PASS")], latest=2)]) + corrupted["committed_at"] = bad_time + second, second_path = _build(store, "2026-09-07T12:00:00Z", passing + [corrupted]) + assert _states(second)["r"] == "FAILURE_OBSERVED" + assert _integrity(second)["evaluation_status"] == "UNKNOWN" + assert verify_dir(second_path) == [] + for day in (8, 9): + bundle, path = _build(store, f"2026-09-{day:02d}T12:00:00Z", passing + [_record("r", 4, [_run(41, [(2, "VERIFY_PASS")], latest=2)])]) + result = _integrity(bundle) + record = next(r for r in result["derived"]["revision_history"]["records"] if r["revision"] == "r") + assert record["committed_at"] == failed["committed_at"] + assert record["history_state"] == "FAILURE_OBSERVED" + assert result["derived"]["failed_count_14d"] == 1 and result["band"] == "FLAKY" + assert verify_dir(path) == [] + + +def test_equivalent_timestamp_and_proven_departure_remain_admissible(tmp_path): + store = FilesystemHistoryStore(tmp_path) + failed = _record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])]) + _build(store, "2026-09-06T12:00:00Z", [failed]) + equivalent = deepcopy(failed); equivalent["committed_at"] = "2026-09-04T12:00:00+02:00" + bundle, _ = _build(store, "2026-09-07T12:00:00Z", [equivalent]) + assert _states(bundle)["r"] == "FAILURE_OBSERVED" + assert not any("IMMUTABLE_COMMIT_TIME_MISMATCH" in d for d in _integrity(bundle)["diagnostics"]) + partial, _ = _build(store, "2026-09-08T12:00:00Z", [], series_status="PARTIAL") + assert _states(partial)["r"] == "FAILURE_OBSERVED" + departed, _ = _build(store, "2026-09-09T12:00:00Z", []) + assert "r" not in _states(departed) + + +@pytest.mark.parametrize("attempts,latest", [([(1, "VERIFY_PASS"), (3, "VERIFY_PASS")], 3), + ([(1, "VERIFY_PASS"), (2, "VERIFY_PASS"), (3, "VERIFY_PASS")], 2), + ([(1, "VERIFY_PASS")], 10**12), ([(1, "UNKNOWN"), (2, "VERIFY_PASS")], 2)]) +def test_incomplete_or_unknown_attempts_never_prove_pass_only(attempts, latest): + parent = parent_from_current(_run(41, attempts, latest=latest), "test") + assert union_state([parent], []) == ("UNKNOWN_HISTORY", False) + + +@pytest.mark.parametrize("lineage", [LINEAGE, REPLAYABLE_LINEAGE]) +def test_unknown_attempts_stay_unknown_in_carried_lineages(lineage): + from devostasis.revision_history import encode, union_record + raw = _record("r", 4, [_run(41, [(1, "UNKNOWN"), (2, "VERIFY_PASS")])]) + records = [raw] if lineage == REPLAYABLE_LINEAGE else [encode(union_record("r", raw["committed_at"], [parent_from_current(raw["parents"][0], "test")], []))] + obs = _obs("2026-09-08T12:00:00Z", [_record("r", 4, [])]) + add(obs, CARRIED, {"lineage": lineage, "records": records}, "record") + assert integrity.evaluate(obs).band is None + assert integrity.evaluate(obs).derived["revision_history"]["records"][0]["history_state"] == "UNKNOWN_HISTORY" + + +def test_unknown_vocabulary_is_rejected_but_legitimate_transitions_and_failures_survive(): + from devostasis.revision_history import HistoryShapeError, merge_parents + with pytest.raises(HistoryShapeError): + parent_from_current(_run(41, [(1, "NOT_A_CI_STATE")]), "test") + pending = parent_from_current(_run(41, [(1, "VERIFY_UNRESOLVED")]), "test") + passed = parent_from_current(_run(41, [(1, "VERIFY_PASS")]), "test") + for first, second in ((pending, passed), (passed, pending)): + merged, conflicts = merge_parents([first], [second]) + assert union_state(merged, conflicts) == ("PASS_ONLY_OBSERVED", True) + failed = parent_from_current(_run(41, [(1, "VERIFY_FAIL"), (3, "UNKNOWN")], latest=3), "test") + assert union_state([failed], []) == ("FAILURE_OBSERVED", False) From b849923511ffef24b3b53d0ed01937ae706f60f8 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 14:26:04 +0300 Subject: [PATCH 8/8] Keep current revision metadata bound to reconciled history --- src/devostasis/vitals/integrity.py | 8 ++++---- tests/test_ci_history_admission.py | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index ba69dd3..75c8c7b 100644 --- a/src/devostasis/vitals/integrity.py +++ b/src/devostasis/vitals/integrity.py @@ -194,7 +194,7 @@ def _bearing(history: Reconciled, revisions: list[dict[str, Any]]) -> list[dict[ return [r for r in records if r["parents"] or r.get("unresolved")] -def _unknown_current(revisions: list[dict[str, Any]], diagnostics: list[str]) -> dict[str, Any]: +def _unknown_current(revisions: list[dict[str, Any]], diagnostics: list[str], history: Reconciled) -> dict[str, Any]: """Retain the accepted current-verdict diagnostic beside an independent admission/history gap.""" active = [r for r in revisions if _record_consistent(r) and r.get("parents") and isinstance(r.get("committed_at"), str)] @@ -210,7 +210,7 @@ def _unknown_current(revisions: list[dict[str, Any]], diagnostics: list[str]) -> diagnostics.append(f"CURRENT_VERDICT_UNKNOWN:{latest['revision']}") return {"unknown_verdict_rule": UNKNOWN_RULE, "current_revision": { "revision": latest["revision"], "committed_at": latest["committed_at"], - "current_verdict": current, "history_state": latest["history_state"]}} + "current_verdict": current, "history_state": history.records.get(latest["revision"], {}).get("history_state", latest["history_state"])}} def evaluate(obs: ObservationSet) -> VitalResult: @@ -249,7 +249,7 @@ def evaluate(obs: ObservationSet) -> VitalResult: # shape its lineage declares, is a coverage defect, not a pass. diagnostics.extend(f"REVISION_RECORD_INCONSISTENT:{_record_label(r)}" for r in inconsistent) diagnostics.extend(reconciled.problems) - return _unknown(obs, {**history, **_unknown_current(revisions, diagnostics)}, diagnostics) + return _unknown(obs, {**history, **_unknown_current(revisions, diagnostics, reconciled)}, diagnostics) active = [r for r in revisions if r.get("parents")] provenance: dict[str, int] = {} for r in active: @@ -295,7 +295,7 @@ def evaluate(obs: ObservationSet) -> VitalResult: # R52 and PV-HIST-002: history nobody can prove is neither a pass nor a # zero, and T9 puts it above every conservative branch. diagnostics.extend(f"REVISION_HISTORY_UNKNOWN:{r['revision']}" for r in unknown_history) - current_gap = _unknown_current(revisions, diagnostics) + current_gap = _unknown_current(revisions, diagnostics, reconciled) if current_gap: current_gap["failure_ratio_14d"] = ratio(f, n) if n else None strength = sample_strength(n) diff --git a/tests/test_ci_history_admission.py b/tests/test_ci_history_admission.py index c519c3e..6f8e8fb 100644 --- a/tests/test_ci_history_admission.py +++ b/tests/test_ci_history_admission.py @@ -119,3 +119,11 @@ def test_unknown_vocabulary_is_rejected_but_legitimate_transitions_and_failures_ assert union_state(merged, conflicts) == ("PASS_ONLY_OBSERVED", True) failed = parent_from_current(_run(41, [(1, "VERIFY_FAIL"), (3, "UNKNOWN")], latest=3), "test") assert union_state([failed], []) == ("FAILURE_OBSERVED", False) + + +def test_current_revision_metadata_matches_its_reconciled_unknown_history(): + from helpers import parent, revision + raw = revision("new", "2026-09-06T10:00:00Z", [parent("p-new", "UNKNOWN")], "UNKNOWN", "NO_DECISIVE_OBSERVED") + result = integrity.evaluate(_obs("2026-09-08T12:00:00Z", _passing([1, 2, 3]) + [raw])) + assert result.derived["current_revision"]["history_state"] == "UNKNOWN_HISTORY" + assert result.derived["unknown_history_count_14d"] == 1