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/attributions.json b/.crab/attributions.json new file mode 100644 index 0000000..3a115e0 --- /dev/null +++ b/.crab/attributions.json @@ -0,0 +1,53 @@ +{ + "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" + }, + { + "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/.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 + } + ] +} 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 new file mode 100644 index 0000000..0bff239 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +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" + groups: + github-actions: + patterns: + - "*" + cooldown: + default-days: 7 + - package-ecosystem: "pip" + directory: "/" + target-branch: "release/0.2.0" + schedule: + interval: "weekly" + day: "tuesday" + cooldown: + default-days: 7 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/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 new file mode 100644 index 0000000..551f36c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,16 @@ +# Third-party notices + +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 + +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 | +| `crab:tooling:tooling.dependabot` | `.github/dependabot.yml` | `.github/dependabot.yml` (adapted) | COPY | 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..3873595 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 @@ -151,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 518e203..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]: @@ -248,10 +256,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) @@ -295,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: @@ -328,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]] = {} @@ -341,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/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/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index 1430dc2..75c8c7b 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], 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)] + 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": history.records.get(latest["revision"], {}).get("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, reconciled)}, 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, reconciled) + 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..6f8e8fb --- /dev/null +++ b/tests/test_ci_history_admission.py @@ -0,0 +1,129 @@ +"""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) + + +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 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]))